New cockpit
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
"Bash(git remote *)",
|
"Bash(git remote *)",
|
||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(git commit -m 'Add Dockerfile for Docker production deployment *)",
|
"Bash(git commit -m 'Add Dockerfile for Docker production deployment *)",
|
||||||
"Bash(git commit -m 'Auto-seed default sources on first startup *)"
|
"Bash(git commit -m 'Auto-seed default sources on first startup *)",
|
||||||
|
"Bash(py -c \"from app import app; print\\('OK'\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
app.py
93
app.py
@@ -46,6 +46,46 @@ _DOMAIN_LABELS = {
|
|||||||
'other': ('Autre', '📌'),
|
'other': ('Autre', '📌'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CONDITION_LABELS = {
|
||||||
|
'hypertension': 'Hypertension',
|
||||||
|
'diabetes_risk': 'Risque diabète',
|
||||||
|
'cardiovascular_risk': 'Risque cardio-vasculaire',
|
||||||
|
'anxiety': 'Anxiété',
|
||||||
|
'digestive_issues': 'Troubles digestifs',
|
||||||
|
'back_pain': 'Douleurs dorsales',
|
||||||
|
'osteoporosis_risk': 'Risque ostéoporose',
|
||||||
|
'metabolic_syndrome': 'Syndrome métabolique',
|
||||||
|
'thyroid': 'Thyroïde',
|
||||||
|
'sleep_apnea': 'Apnée du sommeil',
|
||||||
|
}
|
||||||
|
_GOAL_LABELS = {
|
||||||
|
'weight_loss': 'Perte de poids',
|
||||||
|
'muscle_gain': 'Prise de muscle',
|
||||||
|
'longevity': 'Longévité',
|
||||||
|
'energy': 'Énergie',
|
||||||
|
'better_sleep': 'Améliorer le sommeil',
|
||||||
|
'stress_reduction': 'Réduction du stress',
|
||||||
|
'metabolic_health': 'Santé métabolique',
|
||||||
|
'cardiovascular_health': 'Santé cardio-vasculaire',
|
||||||
|
'cognitive_health': 'Santé cognitive',
|
||||||
|
}
|
||||||
|
_DIET_LABELS = {
|
||||||
|
'omnivore': 'Omnivore',
|
||||||
|
'flexitarian': 'Flexitarien',
|
||||||
|
'vegetarian': 'Végétarien',
|
||||||
|
'vegan': 'Végétalien',
|
||||||
|
'paleo': 'Paléo',
|
||||||
|
'keto': 'Kéto',
|
||||||
|
'other': 'Autre',
|
||||||
|
}
|
||||||
|
_ACTIVITY_LABELS = {
|
||||||
|
'sedentary': 'Sédentaire',
|
||||||
|
'light': 'Légère activité',
|
||||||
|
'moderate': 'Activité modérée',
|
||||||
|
'intense': 'Activité intense',
|
||||||
|
'athlete': 'Athlète',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Models ─────────────────────────────────────────────────────────────────────
|
# ── Models ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -796,9 +836,37 @@ def admin_review_update(rid):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_library():
|
def admin_library():
|
||||||
|
from pipeline import _CONDITIONS, _HEALTH_GOALS, _DIET_TYPES, _ACTIVITY_LEVELS
|
||||||
|
|
||||||
recs = PendingRecommendation.query.filter_by(status='approved') \
|
recs = PendingRecommendation.query.filter_by(status='approved') \
|
||||||
.order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all()
|
.order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all()
|
||||||
return render_template('admin/library.html', recs=recs, domain_labels=_DOMAIN_LABELS)
|
|
||||||
|
tree = {}
|
||||||
|
ev_total = {'A': 0, 'B': 0, 'C': 0}
|
||||||
|
for rec in recs:
|
||||||
|
d = rec.domain
|
||||||
|
ev = rec.evidence_level if rec.evidence_level in ('A', 'B', 'C') else 'C'
|
||||||
|
ev_total[ev] += 1
|
||||||
|
if d not in tree:
|
||||||
|
tree[d] = {'recs': [], 'tags': {}, 'ev': {'A': 0, 'B': 0, 'C': 0}}
|
||||||
|
tree[d]['recs'].append(rec)
|
||||||
|
tree[d]['ev'][ev] += 1
|
||||||
|
for tag in (rec.tags or []):
|
||||||
|
tag = tag.strip().lower()
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
if tag not in tree[d]['tags']:
|
||||||
|
tree[d]['tags'][tag] = {'recs': [], 'ev': {'A': 0, 'B': 0, 'C': 0}}
|
||||||
|
tree[d]['tags'][tag]['recs'].append(rec)
|
||||||
|
tree[d]['tags'][tag]['ev'][ev] += 1
|
||||||
|
|
||||||
|
return render_template('admin/library.html',
|
||||||
|
recs=recs, tree=tree,
|
||||||
|
domain_labels=_DOMAIN_LABELS, ev_total=ev_total,
|
||||||
|
conditions=_CONDITIONS, health_goals=_HEALTH_GOALS,
|
||||||
|
diet_types=_DIET_TYPES, activity_levels=_ACTIVITY_LEVELS,
|
||||||
|
condition_labels=_CONDITION_LABELS, goal_labels=_GOAL_LABELS,
|
||||||
|
diet_labels=_DIET_LABELS, activity_labels=_ACTIVITY_LABELS)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/library/<int:rid>/delete', methods=['POST'])
|
@app.route('/admin/library/<int:rid>/delete', methods=['POST'])
|
||||||
@@ -814,6 +882,29 @@ def admin_library_delete(rid):
|
|||||||
return redirect(url_for('admin_library'))
|
return redirect(url_for('admin_library'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/library/<int:rid>/update', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_library_update(rid):
|
||||||
|
rec = db.get_or_404(PendingRecommendation, rid)
|
||||||
|
f = request.form
|
||||||
|
rec.target_sex = f.get('target_sex', 'all')
|
||||||
|
rec.target_age_min = int(f['target_age_min']) if f.get('target_age_min') else None
|
||||||
|
rec.target_age_max = int(f['target_age_max']) if f.get('target_age_max') else None
|
||||||
|
rec.not_for_under_18 = 'not_for_under_18' in f
|
||||||
|
rec.not_for_pregnant = 'not_for_pregnant' in f
|
||||||
|
rec.required_conditions = f.getlist('required_conditions')
|
||||||
|
rec.excluded_conditions = f.getlist('excluded_conditions')
|
||||||
|
rec.required_health_goals = f.getlist('required_health_goals')
|
||||||
|
rec.required_diet_types = f.getlist('required_diet_types')
|
||||||
|
rec.required_activity_levels = f.getlist('required_activity_levels')
|
||||||
|
rec.priority = int(f.get('priority', rec.priority))
|
||||||
|
rec.admin_notes = f.get('admin_notes', '').strip()
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Ciblage de « {rec.title} » mis à jour.', 'success')
|
||||||
|
return redirect(url_for('admin_library'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/config', methods=['GET', 'POST'])
|
@app.route('/admin/config', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -563,6 +563,112 @@ body {
|
|||||||
border: 1px solid #fde68a;
|
border: 1px solid #fde68a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Library cockpit ──────────────────────────────────────────── */
|
||||||
|
.admin-main--cockpit {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lib-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lib-cockpit {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.lib-tree {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.lib-cards {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.lib-tree-node {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.lib-tree-node:hover { background: #f1f5f9; color: var(--text); }
|
||||||
|
.lib-tree-node.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
|
||||||
|
.lib-tree-tag { padding-left: 1.4rem; font-size: 0.79rem; }
|
||||||
|
.lib-tree-ev {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.63rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.05rem 0.2rem;
|
||||||
|
}
|
||||||
|
.lib-tree-ev.ev-A { background: #dcfce7; color: #166534; }
|
||||||
|
.lib-tree-ev.ev-B { background: #fef9c3; color: #854d0e; }
|
||||||
|
.lib-tree-ev.ev-C { background: #ffedd5; color: #9a3412; }
|
||||||
|
.lib-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.lib-card:hover { box-shadow: var(--shadow); }
|
||||||
|
.lib-card-head {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.lib-card-head:hover { background: #f8fafc; }
|
||||||
|
.lib-card-head[aria-expanded="true"] { background: #f8fafc; }
|
||||||
|
.lib-card-head[aria-expanded="true"] .lib-chevron { transform: rotate(180deg); }
|
||||||
|
.lib-chevron { transition: transform 0.2s; }
|
||||||
|
.targeting-panel {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.multi-check-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.multi-check-item {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.5rem 0.3rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.multi-check-item:has(input:checked) {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Crawl log terminal ───────────────────────────────────────── */
|
/* ── Crawl log terminal ───────────────────────────────────────── */
|
||||||
.crawl-log-box {
|
.crawl-log-box {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
|
|||||||
@@ -5,85 +5,483 @@
|
|||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
{% include 'admin/_sidebar.html' %}
|
{% include 'admin/_sidebar.html' %}
|
||||||
|
|
||||||
<div class="admin-main">
|
<div class="admin-main admin-main--cockpit">
|
||||||
<div class="admin-page-header">
|
|
||||||
|
{# ── Header ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="lib-header px-4 py-3 border-bottom bg-white d-flex justify-content-between align-items-center flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="admin-page-title">
|
<h1 class="admin-page-title mb-0">
|
||||||
<i class="fas fa-book-medical me-2 text-accent"></i>Bibliothèque
|
<i class="fas fa-book-medical me-2 text-accent"></i>Bibliothèque
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
|
<p class="text-muted mb-0 small">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="d-flex gap-1 align-items-center">
|
||||||
|
<span class="evidence-badge ev-A">A <span class="ms-1 opacity-75">{{ ev_total.A }}</span></span>
|
||||||
|
<span class="evidence-badge ev-B">B <span class="ms-1 opacity-75">{{ ev_total.B }}</span></span>
|
||||||
|
<span class="evidence-badge ev-C">C <span class="ms-1 opacity-75">{{ ev_total.C }}</span></span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-inbox me-1"></i>File d'attente
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if recs %}
|
{% if recs %}
|
||||||
{% set current_domain = namespace(val='') %}
|
{# ── Cockpit body ────────────────────────────────────────────────── #}
|
||||||
{% for rec in recs %}
|
<div class="lib-cockpit">
|
||||||
{% if rec.domain != current_domain.val %}
|
|
||||||
{% set current_domain.val = rec.domain %}
|
|
||||||
<div class="admin-domain-divider">
|
|
||||||
{{ domain_labels.get(rec.domain, (rec.domain, ''))[1] }}
|
|
||||||
{{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }}
|
|
||||||
<span class="text-muted ms-1 fw-normal small">
|
|
||||||
({{ recs|selectattr('domain', 'equalto', rec.domain)|list|length }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm rounded-3 mb-2">
|
{# ── Left tree nav ──────────────────────────────────────────────── #}
|
||||||
<div class="card-body p-3">
|
<div class="lib-tree">
|
||||||
<div class="d-flex align-items-start gap-3">
|
<div class="p-2">
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
<button class="lib-tree-node active" data-filter-domain="" data-filter-tag="">
|
||||||
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
|
<span><i class="fas fa-layer-group me-2 opacity-50" style="font-size:.8rem;"></i>Toutes</span>
|
||||||
<span class="badge bg-light text-muted border small">Priorité {{ rec.priority }}</span>
|
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
||||||
<span class="text-muted small">{{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else '—' }}</span>
|
{% if ev_total.A %}<span class="lib-tree-ev ev-A">{{ ev_total.A }}</span>{% endif %}
|
||||||
</div>
|
{% if ev_total.B %}<span class="lib-tree-ev ev-B">{{ ev_total.B }}</span>{% endif %}
|
||||||
<strong>{{ rec.title }}</strong>
|
{% if ev_total.C %}<span class="lib-tree-ev ev-C">{{ ev_total.C }}</span>{% endif %}
|
||||||
<p class="text-muted small mb-1 mt-1">{{ rec.summary }}</p>
|
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ recs|length }}</span>
|
||||||
{% if rec.source_title %}
|
|
||||||
<div class="small text-muted">
|
|
||||||
<i class="fas fa-book me-1"></i>{{ rec.source_title }}
|
|
||||||
{% if rec.source_doi %} — {{ rec.source_doi }}{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Targeting pills -->
|
|
||||||
<div class="d-flex flex-wrap gap-1 mt-2">
|
|
||||||
{% if rec.target_sex and rec.target_sex != 'all' %}
|
|
||||||
<span class="tag-pill">{{ 'Hommes' if rec.target_sex == 'male' else 'Femmes' }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if rec.target_age_min or rec.target_age_max %}
|
|
||||||
<span class="tag-pill">{{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans</span>
|
|
||||||
{% endif %}
|
|
||||||
{% for c in rec.required_conditions %}
|
|
||||||
<span class="tag-pill tag-pill-blue">Si: {{ c.replace('_',' ') }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% for c in rec.excluded_conditions %}
|
|
||||||
<span class="tag-pill tag-pill-red">Exclu: {{ c.replace('_',' ') }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %}
|
|
||||||
<span class="tag-pill tag-pill-green">Universel</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
|
</button>
|
||||||
onsubmit="return confirm('Retirer « {{ rec.title }} » de la bibliothèque ?')">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Retirer">
|
<div class="my-2" style="border-top:1px solid var(--border);"></div>
|
||||||
<i class="fas fa-archive"></i>
|
|
||||||
|
{% for domain_key, label in domain_labels.items() %}
|
||||||
|
{% if domain_key in tree %}
|
||||||
|
{% set node = tree[domain_key] %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<button class="lib-tree-node lib-tree-domain-btn"
|
||||||
|
data-filter-domain="{{ domain_key }}" data-filter-tag=""
|
||||||
|
{% if node.tags %}data-bs-toggle="collapse" data-bs-target="#tt-{{ domain_key }}"{% endif %}>
|
||||||
|
<span class="text-truncate">{{ label[1] }} {{ label[0] }}</span>
|
||||||
|
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
||||||
|
{% if node.ev.A %}<span class="lib-tree-ev ev-A">{{ node.ev.A }}</span>{% endif %}
|
||||||
|
{% if node.ev.B %}<span class="lib-tree-ev ev-B">{{ node.ev.B }}</span>{% endif %}
|
||||||
|
{% if node.ev.C %}<span class="lib-tree-ev ev-C">{{ node.ev.C }}</span>{% endif %}
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ node.recs|length }}</span>
|
||||||
|
{% if node.tags %}<i class="fas fa-chevron-right lib-tree-chevron ms-1 text-muted" style="font-size:.6rem;transition:transform .2s;"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if node.tags %}
|
||||||
|
<div class="collapse" id="tt-{{ domain_key }}">
|
||||||
|
{% for tag, tnode in node.tags.items()|sort %}
|
||||||
|
<button class="lib-tree-node lib-tree-tag"
|
||||||
|
data-filter-domain="{{ domain_key }}" data-filter-tag="{{ tag }}">
|
||||||
|
<span class="text-truncate">
|
||||||
|
<i class="fas fa-hashtag me-1 opacity-40" style="font-size:.6rem;"></i>{{ tag }}
|
||||||
|
</span>
|
||||||
|
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
||||||
|
{% if tnode.ev.A %}<span class="lib-tree-ev ev-A">{{ tnode.ev.A }}</span>{% endif %}
|
||||||
|
{% if tnode.ev.B %}<span class="lib-tree-ev ev-B">{{ tnode.ev.B }}</span>{% endif %}
|
||||||
|
{% if tnode.ev.C %}<span class="lib-tree-ev ev-C">{{ tnode.ev.C }}</span>{% endif %}
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-1" style="font-size:.65rem;">{{ tnode.recs|length }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
{# ── Right cards panel ──────────────────────────────────────────── #}
|
||||||
|
<div class="lib-cards">
|
||||||
|
|
||||||
|
<div id="lib-empty" class="text-center py-5 text-muted d-none">
|
||||||
|
<i class="fas fa-filter fa-2x mb-3 opacity-25"></i>
|
||||||
|
<p class="mb-0">Aucune recommandation dans cette catégorie.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for rec in recs %}
|
||||||
|
{% set is_universal = not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %}
|
||||||
|
{% set has_gates = rec.not_for_under_18 or rec.not_for_pregnant or (rec.target_sex and rec.target_sex != 'all') or rec.target_age_min or rec.target_age_max or rec.excluded_conditions %}
|
||||||
|
|
||||||
|
<div class="lib-card mb-2"
|
||||||
|
data-domain="{{ rec.domain }}"
|
||||||
|
data-tags="{{ rec.tags | join(' ') | lower }}"
|
||||||
|
id="rec-{{ rec.id }}">
|
||||||
|
|
||||||
|
{# Card header — click to expand #}
|
||||||
|
<div class="lib-card-head d-flex align-items-start gap-2 p-3"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#cb-{{ rec.id }}"
|
||||||
|
aria-expanded="false" aria-controls="cb-{{ rec.id }}">
|
||||||
|
<span class="evidence-badge ev-{{ rec.evidence_level }} flex-shrink-0" style="margin-top:2px;">{{ rec.evidence_level }}</span>
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="fw-semibold" style="line-height:1.3;">{{ rec.title }}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mt-1">
|
||||||
|
{% if is_universal %}
|
||||||
|
<span class="tag-pill tag-pill-green">🌍 Universel</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag-pill tag-pill-blue">🎯 Ciblé</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if rec.target_sex == 'male' %}<span class="tag-pill">♂ Hommes</span>
|
||||||
|
{% elif rec.target_sex == 'female' %}<span class="tag-pill">♀ Femmes</span>{% endif %}
|
||||||
|
{% if rec.target_age_min or rec.target_age_max %}
|
||||||
|
<span class="tag-pill">{{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans</span>
|
||||||
|
{% endif %}
|
||||||
|
{% for t in rec.tags[:4] %}<span class="tag-pill">{{ t }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-down lib-chevron text-muted flex-shrink-0" style="margin-top:4px;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Card body — collapsible #}
|
||||||
|
<div class="collapse" id="cb-{{ rec.id }}">
|
||||||
|
<div class="lib-card-body border-top px-3 pb-3 pt-2">
|
||||||
|
|
||||||
|
{% if rec.summary %}
|
||||||
|
<p class="text-muted small mb-2">{{ rec.summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if rec.details %}
|
||||||
|
<p class="small mb-3 text-secondary">{{ rec.details }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Intervention + Effect #}
|
||||||
|
{% if rec.intervention or rec.effect %}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
{% if rec.intervention %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-2 rounded-2" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||||
|
<div class="small fw-semibold text-success mb-1"><i class="fas fa-play-circle me-1"></i>Intervention</div>
|
||||||
|
<div class="small">{{ rec.intervention }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if rec.effect %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-2 rounded-2" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||||
|
<div class="small fw-semibold text-primary mb-1"><i class="fas fa-chart-line me-1"></i>Effet attendu</div>
|
||||||
|
<div class="small">{{ rec.effect }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Source citation #}
|
||||||
|
{% if rec.source_title %}
|
||||||
|
<div class="small text-muted mb-3 p-2 rounded-2" style="background:#f8fafc;border:1px solid var(--border);">
|
||||||
|
<i class="fas fa-book me-1"></i>
|
||||||
|
<em>{{ rec.source_title }}</em>
|
||||||
|
{% if rec.source_doi %} — <code class="small">{{ rec.source_doi }}</code>{% endif %}
|
||||||
|
{% if rec.study_type %}<span class="tag-pill ms-1">{{ rec.study_type.replace('_',' ') }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Targeting breakdown ─────────────────────────────────── #}
|
||||||
|
<div class="targeting-panel p-3 mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="text-uppercase fw-bold text-muted" style="font-size:.65rem;letter-spacing:.06em;">Ciblage profil</span>
|
||||||
|
{% if is_universal %}
|
||||||
|
<span class="tag-pill tag-pill-green">🌍 Universel — affiché à tous</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag-pill tag-pill-blue">🎯 Conditionnel</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if has_gates %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="small fw-semibold text-danger mb-1" style="font-size:.75rem;">
|
||||||
|
<i class="fas fa-ban me-1"></i>Exclusions (portes dures — toutes doivent passer)
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% if rec.not_for_under_18 %}<span class="tag-pill tag-pill-red">✗ Pas < 18 ans</span>{% endif %}
|
||||||
|
{% if rec.not_for_pregnant %}<span class="tag-pill tag-pill-red">✗ Pas grossesse</span>{% endif %}
|
||||||
|
{% if rec.target_sex == 'male' %}<span class="tag-pill tag-pill-red">♂ Hommes uniquement</span>{% endif %}
|
||||||
|
{% if rec.target_sex == 'female' %}<span class="tag-pill tag-pill-red">♀ Femmes uniquement</span>{% endif %}
|
||||||
|
{% if rec.target_age_min %}<span class="tag-pill tag-pill-red">≥ {{ rec.target_age_min }} ans</span>{% endif %}
|
||||||
|
{% if rec.target_age_max %}<span class="tag-pill tag-pill-red">≤ {{ rec.target_age_max }} ans</span>{% endif %}
|
||||||
|
{% for c in rec.excluded_conditions %}
|
||||||
|
<span class="tag-pill tag-pill-red">Exclu si: {{ condition_labels.get(c, c) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not is_universal %}
|
||||||
|
<div>
|
||||||
|
<div class="small fw-semibold text-primary mb-1" style="font-size:.75rem;">
|
||||||
|
<i class="fas fa-bolt me-1"></i>Déclencheurs — logique OU (un groupe suffit)
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for c in rec.required_conditions %}
|
||||||
|
<span class="tag-pill tag-pill-blue">Si: {{ condition_labels.get(c, c) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% for g in rec.required_health_goals %}
|
||||||
|
<span class="tag-pill tag-pill-blue">Objectif: {{ goal_labels.get(g, g) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% for d in rec.required_diet_types %}
|
||||||
|
<span class="tag-pill tag-pill-blue">Régime: {{ diet_labels.get(d, d) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% for a in rec.required_activity_levels %}
|
||||||
|
<span class="tag-pill tag-pill-blue">Activité: {{ activity_labels.get(a, a) }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>Aucun critère de déclenchement — affichée à tous les utilisateurs (sous réserve des exclusions ci-dessus si définies).
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Actions #}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modal-{{ rec.id }}">
|
||||||
|
<i class="fas fa-sliders-h me-1"></i>Modifier le ciblage
|
||||||
|
</button>
|
||||||
|
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
|
||||||
|
onsubmit="return confirm('Retirer « {{ rec.title|replace("'", '') }} » de la bibliothèque ?')">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-archive me-1"></i>Archiver
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Edit targeting modal ───────────────────────────────────── #}
|
||||||
|
<div class="modal fade" id="modal-{{ rec.id }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form action="{{ url_for('admin_library_update', rid=rec.id) }}" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-sliders-h me-2"></i>Ciblage
|
||||||
|
<span class="text-muted fw-normal small ms-1">— {{ rec.title[:60] }}{% if rec.title|length > 60 %}…{% endif %}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
{# Demographics #}
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Sexe cible</label>
|
||||||
|
<select name="target_sex" class="form-select form-select-sm">
|
||||||
|
<option value="all" {{ 'selected' if rec.target_sex in ('all', None, '') }}>Tous</option>
|
||||||
|
<option value="male" {{ 'selected' if rec.target_sex == 'male' }}>Hommes ♂</option>
|
||||||
|
<option value="female" {{ 'selected' if rec.target_sex == 'female' }}>Femmes ♀</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Âge min</label>
|
||||||
|
<input type="number" name="target_age_min" class="form-control form-control-sm"
|
||||||
|
value="{{ rec.target_age_min or '' }}" placeholder="—" min="0" max="120">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Âge max</label>
|
||||||
|
<input type="number" name="target_age_max" class="form-control form-control-sm"
|
||||||
|
value="{{ rec.target_age_max or '' }}" placeholder="—" min="0" max="120">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Priorité (1–10)</label>
|
||||||
|
<input type="number" name="priority" class="form-control form-control-sm"
|
||||||
|
value="{{ rec.priority }}" min="1" max="10">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 d-flex align-items-end gap-4 pb-1">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="not_for_under_18" class="form-check-input"
|
||||||
|
id="u18-{{ rec.id }}" {{ 'checked' if rec.not_for_under_18 }}>
|
||||||
|
<label class="form-check-label small" for="u18-{{ rec.id }}">
|
||||||
|
<span class="tag-pill tag-pill-red">Exclure < 18 ans</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="not_for_pregnant" class="form-check-input"
|
||||||
|
id="preg-{{ rec.id }}" {{ 'checked' if rec.not_for_pregnant }}>
|
||||||
|
<label class="form-check-label small" for="preg-{{ rec.id }}">
|
||||||
|
<span class="tag-pill tag-pill-red">Exclure grossesse</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
{# Conditions #}
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small fw-semibold text-danger">
|
||||||
|
<i class="fas fa-ban me-1"></i>Conditions exclues
|
||||||
|
<span class="text-muted fw-normal">(porte dure)</span>
|
||||||
|
</label>
|
||||||
|
<div class="multi-check-grid">
|
||||||
|
{% for c in conditions %}
|
||||||
|
<label class="multi-check-item form-check">
|
||||||
|
<input type="checkbox" name="excluded_conditions" value="{{ c }}"
|
||||||
|
class="form-check-input" {{ 'checked' if c in rec.excluded_conditions }}>
|
||||||
|
{{ condition_labels.get(c, c) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small fw-semibold text-primary">
|
||||||
|
<i class="fas fa-bolt me-1"></i>Conditions déclenchantes
|
||||||
|
<span class="text-muted fw-normal">(OU)</span>
|
||||||
|
</label>
|
||||||
|
<div class="multi-check-grid">
|
||||||
|
{% for c in conditions %}
|
||||||
|
<label class="multi-check-item form-check">
|
||||||
|
<input type="checkbox" name="required_conditions" value="{{ c }}"
|
||||||
|
class="form-check-input" {{ 'checked' if c in rec.required_conditions }}>
|
||||||
|
{{ condition_labels.get(c, c) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Goals + Diet #}
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small fw-semibold text-primary">
|
||||||
|
<i class="fas fa-bullseye me-1"></i>Objectifs santé
|
||||||
|
<span class="text-muted fw-normal">(OU)</span>
|
||||||
|
</label>
|
||||||
|
<div class="multi-check-grid">
|
||||||
|
{% for g in health_goals %}
|
||||||
|
<label class="multi-check-item form-check">
|
||||||
|
<input type="checkbox" name="required_health_goals" value="{{ g }}"
|
||||||
|
class="form-check-input" {{ 'checked' if g in rec.required_health_goals }}>
|
||||||
|
{{ goal_labels.get(g, g) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small fw-semibold text-primary">
|
||||||
|
<i class="fas fa-utensils me-1"></i>Types de régime
|
||||||
|
<span class="text-muted fw-normal">(OU)</span>
|
||||||
|
</label>
|
||||||
|
<div class="multi-check-grid">
|
||||||
|
{% for d in diet_types %}
|
||||||
|
<label class="multi-check-item form-check">
|
||||||
|
<input type="checkbox" name="required_diet_types" value="{{ d }}"
|
||||||
|
class="form-check-input" {{ 'checked' if d in rec.required_diet_types }}>
|
||||||
|
{{ diet_labels.get(d, d) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Activity #}
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small fw-semibold text-primary">
|
||||||
|
<i class="fas fa-running me-1"></i>Niveaux d'activité
|
||||||
|
<span class="text-muted fw-normal">(OU)</span>
|
||||||
|
</label>
|
||||||
|
<div class="multi-check-grid">
|
||||||
|
{% for a in activity_levels %}
|
||||||
|
<label class="multi-check-item form-check">
|
||||||
|
<input type="checkbox" name="required_activity_levels" value="{{ a }}"
|
||||||
|
class="form-check-input" {{ 'checked' if a in rec.required_activity_levels }}>
|
||||||
|
{{ activity_labels.get(a, a) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label small fw-semibold">Notes admin</label>
|
||||||
|
<textarea name="admin_notes" class="form-control form-control-sm" rows="2"
|
||||||
|
placeholder="Notes internes (non visibles par les utilisateurs)">{{ rec.admin_notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-save me-1"></i>Enregistrer le ciblage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
<i class="fas fa-book-medical fa-3x mb-3 opacity-25"></i>
|
<i class="fas fa-book-medical fa-3x mb-3 opacity-25"></i>
|
||||||
<p class="mb-0">Aucune recommandation approuvée. Allez dans la file d'attente pour en valider.</p>
|
<p class="mb-0">Aucune recommandation approuvée. Allez dans la file d'attente pour en valider.</p>
|
||||||
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary mt-3">
|
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary mt-3">
|
||||||
<i class="fas fa-inbox me-1"></i>Aller à la file d'attente
|
<i class="fas fa-inbox me-1"></i>File d'attente
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ── Tree filtering ────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.lib-tree-node').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var domain = this.dataset.filterDomain;
|
||||||
|
var tag = this.dataset.filterTag;
|
||||||
|
|
||||||
|
document.querySelectorAll('.lib-tree-node').forEach(function (b) {
|
||||||
|
b.classList.remove('active');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
var visible = 0;
|
||||||
|
document.querySelectorAll('.lib-card').forEach(function (card) {
|
||||||
|
var show;
|
||||||
|
if (!domain) {
|
||||||
|
show = true;
|
||||||
|
} else if (!tag) {
|
||||||
|
show = card.dataset.domain === domain;
|
||||||
|
} else {
|
||||||
|
var tags = (card.dataset.tags || '').split(' ');
|
||||||
|
show = card.dataset.domain === domain && tags.indexOf(tag) !== -1;
|
||||||
|
}
|
||||||
|
card.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
|
||||||
|
var emptyEl = document.getElementById('lib-empty');
|
||||||
|
if (emptyEl) emptyEl.classList.toggle('d-none', visible > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tree chevron rotation on collapse ────────────────────────────
|
||||||
|
document.querySelectorAll('.lib-tree-domain-btn[data-bs-toggle="collapse"]').forEach(function (btn) {
|
||||||
|
var target = document.querySelector(btn.dataset.bsTarget);
|
||||||
|
if (!target) return;
|
||||||
|
var chevron = btn.querySelector('.lib-tree-chevron');
|
||||||
|
if (!chevron) return;
|
||||||
|
target.addEventListener('show.bs.collapse', function () {
|
||||||
|
chevron.style.transform = 'rotate(90deg)';
|
||||||
|
});
|
||||||
|
target.addEventListener('hide.bs.collapse', function () {
|
||||||
|
chevron.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user