Initial production-ready release of OpenCare v1
- Flask/SQLAlchemy app with profile, dashboard, recommendations - AI crawl pipeline (GPT-4o) with admin review workflow - 14 curated health sources (RSS + index crawling) - Production config: env vars, Gunicorn, systemd, nginx - deploy/ scripts for VPS setup and updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
templates/403.html
Normal file
12
templates/403.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Accès refusé — OpenCare{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5 text-center">
|
||||
<div class="empty-state mx-auto">
|
||||
<div class="empty-icon mb-3">🚫</div>
|
||||
<h2 class="fw-bold">Accès refusé</h2>
|
||||
<p class="text-muted">Vous n'avez pas les droits pour accéder à cette page.</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary mt-2">Retour au tableau de bord</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
templates/admin/_sidebar.html
Normal file
56
templates/admin/_sidebar.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<nav class="admin-sidebar">
|
||||
<div class="admin-sidebar-brand">
|
||||
<i class="fas fa-shield-alt me-2"></i>Administration
|
||||
</div>
|
||||
|
||||
<ul class="admin-nav">
|
||||
<li>
|
||||
<a href="{{ url_for('admin_index') }}"
|
||||
class="admin-nav-link {% if request.endpoint == 'admin_index' %}active{% endif %}">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span>Tableau de bord</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_sources') }}"
|
||||
class="admin-nav-link {% if request.endpoint in ('admin_sources',) %}active{% endif %}">
|
||||
<i class="fas fa-rss"></i>
|
||||
<span>Sources</span>
|
||||
<span class="admin-badge ms-auto">{{ CrawlSource.query.filter_by(active=True).count() }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_review', status='pending') }}"
|
||||
class="admin-nav-link {% if request.endpoint == 'admin_review' %}active{% endif %}">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span>File d'attente</span>
|
||||
{% set pending_count = PendingRecommendation.query.filter_by(status='pending').count() %}
|
||||
{% if pending_count > 0 %}
|
||||
<span class="admin-badge admin-badge-danger ms-auto">{{ pending_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_library') }}"
|
||||
class="admin-nav-link {% if request.endpoint == 'admin_library' %}active{% endif %}">
|
||||
<i class="fas fa-book-medical"></i>
|
||||
<span>Bibliothèque</span>
|
||||
<span class="admin-badge ms-auto">{{ PendingRecommendation.query.filter_by(status='approved').count() }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin_config') }}"
|
||||
class="admin-nav-link {% if request.endpoint == 'admin_config' %}active{% endif %}">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Configuration</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
<a href="{{ url_for('dashboard') }}" class="admin-nav-link">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Retour à l'app</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
132
templates/admin/config.html
Normal file
132
templates/admin/config.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Configuration — Admin OpenCare{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
{% include 'admin/_sidebar.html' %}
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-page-header">
|
||||
<h1 class="admin-page-title">
|
||||
<i class="fas fa-cog me-2 text-accent"></i>Configuration
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Paramètres du pipeline de veille</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('admin_config') }}">
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- OpenAI -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="fas fa-robot me-2 text-accent"></i>OpenAI
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium">Clé API OpenAI</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-key"></i></span>
|
||||
<input type="password" name="openai_api_key" class="form-control"
|
||||
placeholder="{% if config.openai_api_key %}••••••••••••••••••••••••{% else %}sk-...{% endif %}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{% if config.openai_api_key %}
|
||||
<span class="text-success"><i class="fas fa-check-circle me-1"></i>Clé configurée.</span>
|
||||
Laissez vide pour conserver la clé actuelle.
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="fas fa-exclamation-circle me-1"></i>Aucune clé configurée — le crawl ne peut pas s'exécuter.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">Modèle</label>
|
||||
<select name="openai_model" class="form-select">
|
||||
<option value="gpt-4o" {% if config.openai_model == 'gpt-4o' %}selected{% endif %}>
|
||||
GPT-4o (recommandé)
|
||||
</option>
|
||||
<option value="gpt-4o-mini" {% if config.openai_model == 'gpt-4o-mini' %}selected{% endif %}>
|
||||
GPT-4o mini (économique)
|
||||
</option>
|
||||
<option value="gpt-4-turbo" {% if config.openai_model == 'gpt-4-turbo' %}selected{% endif %}>
|
||||
GPT-4 Turbo
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-text">GPT-4o offre la meilleure extraction structurée.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="fas fa-clock me-2 text-accent"></i>Planification du crawl
|
||||
</h5>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Heure (0–23)</label>
|
||||
<input type="number" name="crawl_hour" class="form-control"
|
||||
min="0" max="23" value="{{ config.crawl_hour }}">
|
||||
<div class="form-text">Heure locale du serveur</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Minute (0–59)</label>
|
||||
<input type="number" name="crawl_minute" class="form-control"
|
||||
min="0" max="59" value="{{ config.crawl_minute }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="info-box">
|
||||
<i class="fas fa-info-circle me-1 text-primary"></i>
|
||||
Crawl automatique chaque nuit à
|
||||
<strong>{{ config.crawl_hour }}h{{ '%02d'|format(config.crawl_minute|int) }}</strong>.
|
||||
Les résultats apparaissent en file d'attente le lendemain matin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Limits -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="fas fa-sliders-h me-2 text-accent"></i>Limites
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">Articles max par crawl</label>
|
||||
<input type="number" name="max_articles_per_run" class="form-control"
|
||||
min="1" max="200" value="{{ config.max_articles_per_run }}">
|
||||
<div class="form-text">Limite le nombre d'appels API OpenAI par nuit.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="info-box mt-4">
|
||||
<i class="fas fa-coins me-1 text-primary"></i>
|
||||
<strong>Coût estimé :</strong> ~0.01–0.05 USD par article (GPT-4o).
|
||||
20 articles/nuit ≈ 0.20–1.00 USD/nuit.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="fas fa-save me-2"></i>Enregistrer la configuration
|
||||
</button>
|
||||
<a href="{{ url_for('admin_index') }}" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
141
templates/admin/index.html
Normal file
141
templates/admin/index.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Admin — OpenCare{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
{% include 'admin/_sidebar.html' %}
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-page-header">
|
||||
<h1 class="admin-page-title">
|
||||
<i class="fas fa-chart-pie me-2 text-accent"></i>Tableau de bord
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Vue d'ensemble du pipeline de veille</p>
|
||||
</div>
|
||||
|
||||
{% if not api_key_set %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 rounded-3 mb-4">
|
||||
<i class="fas fa-exclamation-triangle fa-lg"></i>
|
||||
<div>
|
||||
<strong>Clé API OpenAI manquante.</strong>
|
||||
Le crawl ne peut pas s'exécuter.
|
||||
<a href="{{ url_for('admin_config') }}" class="alert-link ms-1">Configurer →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon" style="background:#eff6ff;color:#2563eb">
|
||||
<i class="fas fa-rss"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-num">{{ stats.sources_active }}<span class="admin-stat-total">/{{ stats.sources }}</span></div>
|
||||
<div class="admin-stat-lbl">Sources actives</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon" style="background:#fff7ed;color:#c2410c">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-num">{{ stats.articles }}</div>
|
||||
<div class="admin-stat-lbl">Articles crawlés</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="admin-stat-card border-warning-subtle">
|
||||
<div class="admin-stat-icon" style="background:#fefce8;color:#854d0e">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-num">{{ stats.pending }}</div>
|
||||
<div class="admin-stat-lbl">En attente de validation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon" style="background:#f0fdf4;color:#166534">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-num">{{ stats.approved }}</div>
|
||||
<div class="admin-stat-lbl">Recommandations actives</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Quick actions -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm rounded-3 h-100">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-bolt me-2 text-accent"></i>Actions rapides</h5>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary">
|
||||
<i class="fas fa-inbox me-2"></i>
|
||||
Réviser la file d'attente
|
||||
{% if stats.pending > 0 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ stats.pending }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('admin_sources') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-plus me-2"></i>Ajouter une source
|
||||
</a>
|
||||
<form action="{{ url_for('admin_crawl_run') }}" method="post" class="d-grid">
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-sync me-2"></i>Lancer un crawl maintenant
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm rounded-3 h-100">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-info-circle me-2 text-accent"></i>État du pipeline</h5>
|
||||
<ul class="list-unstyled mb-0 admin-status-list">
|
||||
<li>
|
||||
<span class="text-muted">Dernier crawl</span>
|
||||
<strong>
|
||||
{% if last_crawl %}
|
||||
{{ last_crawl.strftime('%d/%m/%Y à %H:%M') }}
|
||||
{% else %}
|
||||
Jamais exécuté
|
||||
{% endif %}
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-muted">Clé OpenAI</span>
|
||||
{% if api_key_set %}
|
||||
<span class="badge bg-success">Configurée</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Manquante</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-muted">Crawl planifié</span>
|
||||
<strong>{{ CrawlConfig.get('crawl_hour', '2') }}h{{ CrawlConfig.get('crawl_minute', '00') }} (heure locale)</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-muted">Articles rejetés</span>
|
||||
<strong>{{ stats.rejected }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
89
templates/admin/library.html
Normal file
89
templates/admin/library.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Bibliothèque — Admin OpenCare{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
{% include 'admin/_sidebar.html' %}
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-title">
|
||||
<i class="fas fa-book-medical me-2 text-accent"></i>Bibliothèque
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recs %}
|
||||
{% set current_domain = namespace(val='') %}
|
||||
{% for rec in recs %}
|
||||
{% 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">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
|
||||
<span class="badge bg-light text-muted border small">Priorité {{ rec.priority }}</span>
|
||||
<span class="text-muted small">{{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else '—' }}</span>
|
||||
</div>
|
||||
<strong>{{ rec.title }}</strong>
|
||||
<p class="text-muted small mb-1 mt-1">{{ rec.summary }}</p>
|
||||
{% 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>
|
||||
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
|
||||
onsubmit="return confirm('Retirer « {{ rec.title }} » de la bibliothèque ?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Retirer">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<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>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
443
templates/admin/review.html
Normal file
443
templates/admin/review.html
Normal file
@@ -0,0 +1,443 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Révision — Admin OpenCare{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
{% include 'admin/_sidebar.html' %}
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-title">
|
||||
<i class="fas fa-inbox me-2 text-accent"></i>File de révision
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Validez les recommandations extraites par l'IA avant publication</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status filter tabs -->
|
||||
<ul class="nav nav-pills mb-4 gap-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if status_filter == 'pending' %}active{% endif %}"
|
||||
href="{{ url_for('admin_review', status='pending') }}">
|
||||
En attente <span class="badge bg-warning text-dark ms-1">{{ counts.pending }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if status_filter == 'approved' %}active{% endif %}"
|
||||
href="{{ url_for('admin_review', status='approved') }}">
|
||||
Approuvées <span class="badge bg-success ms-1">{{ counts.approved }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if status_filter == 'rejected' %}active{% endif %}"
|
||||
href="{{ url_for('admin_review', status='rejected') }}">
|
||||
Rejetées <span class="badge bg-secondary ms-1">{{ counts.rejected }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if recs %}
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for rec in recs %}
|
||||
<div class="card border-0 shadow-sm rounded-3 review-card">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-start gap-3 flex-wrap">
|
||||
<!-- Left: content -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="badge admin-domain-badge">
|
||||
{{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }}
|
||||
</span>
|
||||
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
|
||||
{% if rec.study_type %}
|
||||
<span class="badge bg-light text-muted border">{{ rec.study_type }}</span>
|
||||
{% endif %}
|
||||
<span class="text-muted small ms-auto">{{ rec.created_at.strftime('%d/%m/%Y') }}</span>
|
||||
</div>
|
||||
|
||||
<h5 class="fw-bold mb-1">{{ rec.title }}</h5>
|
||||
<p class="text-muted mb-2">{{ rec.summary }}</p>
|
||||
|
||||
{% if rec.details %}
|
||||
<p class="small mb-2 text-secondary">{{ rec.details[:200] }}{% if rec.details|length > 200 %}…{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Source -->
|
||||
{% if rec.source_url or rec.source_title %}
|
||||
<div class="source-box mb-2 small">
|
||||
<i class="fas fa-book me-1"></i>
|
||||
{% if rec.source_title %}<strong>{{ rec.source_title }}</strong>{% endif %}
|
||||
{% if rec.source_doi %} — DOI: {{ rec.source_doi }}{% endif %}
|
||||
{% if rec.source_url %}
|
||||
<a href="{{ rec.source_url }}" target="_blank" rel="noopener" class="ms-1">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Targeting summary -->
|
||||
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||
{% if rec.target_age_min or rec.target_age_max %}
|
||||
<span class="tag-pill">
|
||||
<i class="fas fa-user me-1"></i>
|
||||
{{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if rec.target_sex and rec.target_sex != 'all' %}
|
||||
<span class="tag-pill">{{ 'Homme' if rec.target_sex == 'male' else 'Femme' }}</span>
|
||||
{% endif %}
|
||||
{% if rec.not_for_under_18 %}
|
||||
<span class="tag-pill tag-pill-warn">18+</span>
|
||||
{% endif %}
|
||||
{% if rec.not_for_pregnant %}
|
||||
<span class="tag-pill tag-pill-warn">Pas pour grossesse</span>
|
||||
{% endif %}
|
||||
{% for c in rec.required_conditions %}
|
||||
<span class="tag-pill tag-pill-blue">Si: {{ c }}</span>
|
||||
{% endfor %}
|
||||
{% for c in rec.excluded_conditions %}
|
||||
<span class="tag-pill tag-pill-red">Exclu si: {{ c }}</span>
|
||||
{% endfor %}
|
||||
{% for g in rec.required_health_goals %}
|
||||
<span class="tag-pill tag-pill-green">Objectif: {{ g }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: action buttons -->
|
||||
<div class="d-flex flex-column gap-2" style="min-width:140px">
|
||||
{% if status_filter == 'pending' %}
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
onclick="openEditModal({{ rec.to_edit_dict()|tojson }})">
|
||||
<i class="fas fa-edit me-1"></i>Modifier
|
||||
</button>
|
||||
<form action="{{ url_for('admin_review_approve', rid=rec.id) }}" method="post">
|
||||
<button type="submit" class="btn btn-success btn-sm w-100">
|
||||
<i class="fas fa-check me-1"></i>Approuver
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-reject-id="{{ rec.id }}"
|
||||
data-reject-title="{{ rec.title | e }}"
|
||||
onclick="openRejectModal(this.dataset.rejectId, this.dataset.rejectTitle)">
|
||||
<i class="fas fa-times me-1"></i>Rejeter
|
||||
</button>
|
||||
{% elif status_filter == 'approved' %}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
onclick="openEditModal({{ rec.to_edit_dict()|tojson }})">
|
||||
<i class="fas fa-edit me-1"></i>Modifier
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if rec.admin_notes %}
|
||||
<div class="admin-note small text-muted mt-1">
|
||||
<i class="fas fa-sticky-note me-1"></i>{{ rec.admin_notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-inbox fa-3x mb-3 opacity-25"></i>
|
||||
<p class="mb-0">
|
||||
{% if status_filter == 'pending' %}
|
||||
Aucune recommandation en attente. Lancez un crawl pour en générer.
|
||||
{% elif status_filter == 'approved' %}
|
||||
Aucune recommandation approuvée.
|
||||
{% else %}
|
||||
Aucune recommandation rejetée.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edit Modal ───────────────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="fas fa-edit me-2 text-accent"></i>Modifier la recommandation
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editForm" method="post">
|
||||
<div class="modal-body px-4">
|
||||
<div class="row g-3">
|
||||
<!-- Domain & Evidence -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">Domaine</label>
|
||||
<select name="domain" id="edit_domain" class="form-select">
|
||||
{% for key, (label, icon) in domain_labels.items() %}
|
||||
<option value="{{ key }}">{{ icon }} {{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">Niveau de preuve</label>
|
||||
<select name="evidence_level" id="edit_evidence_level" class="form-select">
|
||||
<option value="A">A — Méta-analyse / RCT solide</option>
|
||||
<option value="B">B — Observationnel / RCT limité</option>
|
||||
<option value="C">C — Expert / Anecdote</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">Type d'étude</label>
|
||||
<select name="study_type" id="edit_study_type" class="form-select">
|
||||
<option value="meta-analysis">Méta-analyse</option>
|
||||
<option value="RCT">RCT</option>
|
||||
<option value="observational">Observationnel</option>
|
||||
<option value="expert_opinion">Avis expert</option>
|
||||
<option value="anecdote">Anecdote</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Titre</label>
|
||||
<input type="text" name="title" id="edit_title" class="form-control" maxlength="256" required>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Résumé</label>
|
||||
<textarea name="summary" id="edit_summary" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Détails</label>
|
||||
<textarea name="details" id="edit_details" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Intervention / Effect -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Intervention concrète</label>
|
||||
<input type="text" name="intervention" id="edit_intervention" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Effet attendu</label>
|
||||
<input type="text" name="effect" id="edit_effect" class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Titre de la source</label>
|
||||
<input type="text" name="source_title" id="edit_source_title" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">DOI</label>
|
||||
<input type="text" name="source_doi" id="edit_source_doi" class="form-control" placeholder="10.xxx/...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Priorité (1–10)</label>
|
||||
<input type="number" name="priority" id="edit_priority" class="form-control" min="1" max="10">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">URL source</label>
|
||||
<input type="url" name="source_url" id="edit_source_url" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-1"></div>
|
||||
|
||||
<!-- Targeting -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Sexe cible</label>
|
||||
<select name="target_sex" id="edit_target_sex" class="form-select">
|
||||
<option value="all">Tous</option>
|
||||
<option value="male">Hommes</option>
|
||||
<option value="female">Femmes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label fw-medium">Âge min</label>
|
||||
<input type="number" name="target_age_min" id="edit_target_age_min" class="form-control" placeholder="—">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label fw-medium">Âge max</label>
|
||||
<input type="number" name="target_age_max" id="edit_target_age_max" class="form-control" placeholder="—">
|
||||
</div>
|
||||
<div class="col-md-5 d-flex align-items-end gap-3 pb-1">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="not_for_under_18" id="edit_under18">
|
||||
<label class="form-check-label" for="edit_under18">Déconseillé -18 ans</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="not_for_pregnant" id="edit_pregnant">
|
||||
<label class="form-check-label" for="edit_pregnant">Déconseillé grossesse</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Déclenché si l'utilisateur a une de ces conditions</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="edit_required_conditions">
|
||||
{% for c in ['hypertension','diabetes_risk','cardiovascular_risk','anxiety','digestive_issues','back_pain','osteoporosis_risk','metabolic_syndrome','thyroid','sleep_apnea'] %}
|
||||
<label class="admin-check-pill">
|
||||
<input type="checkbox" name="required_conditions" value="{{ c }}"> {{ c.replace('_', ' ') }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Ne pas afficher si l'utilisateur a</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="edit_excluded_conditions">
|
||||
{% for c in ['hypertension','diabetes_risk','cardiovascular_risk','anxiety','digestive_issues','back_pain','osteoporosis_risk','metabolic_syndrome','thyroid','sleep_apnea'] %}
|
||||
<label class="admin-check-pill">
|
||||
<input type="checkbox" name="excluded_conditions" value="{{ c }}"> {{ c.replace('_', ' ') }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goals -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Objectifs de santé associés</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for g in ['weight_loss','muscle_gain','longevity','energy','better_sleep','stress_reduction','metabolic_health','cardiovascular_health','cognitive_health'] %}
|
||||
<label class="admin-check-pill">
|
||||
<input type="checkbox" name="required_health_goals" value="{{ g }}" class="edit_goals"> {{ g.replace('_', ' ') }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diet & Activity -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Types de régime ciblés</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for d in ['omnivore','flexitarian','vegetarian','vegan','paleo','keto','other'] %}
|
||||
<label class="admin-check-pill">
|
||||
<input type="checkbox" name="required_diet_types" value="{{ d }}" class="edit_diets"> {{ d }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Niveaux d'activité ciblés</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for a in ['sedentary','light','moderate','intense','athlete'] %}
|
||||
<label class="admin-check-pill">
|
||||
<input type="checkbox" name="required_activity_levels" value="{{ a }}" class="edit_activity"> {{ a }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Notes admin (privées)</label>
|
||||
<input type="text" name="admin_notes" id="edit_admin_notes" class="form-control" placeholder="Remarques internes…">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-0 pt-0 gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="submit" name="action" value="save" class="btn btn-outline-primary">
|
||||
<i class="fas fa-save me-1"></i>Enregistrer
|
||||
</button>
|
||||
<button type="submit" name="action" value="approve" class="btn btn-success">
|
||||
<i class="fas fa-check me-1"></i>Enregistrer & Approuver
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Reject Modal ─────────────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="rejectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title fw-bold">
|
||||
<i class="fas fa-times-circle me-2 text-danger"></i>Rejeter la recommandation
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="rejectForm" method="post">
|
||||
<div class="modal-body">
|
||||
<p class="text-muted mb-3" id="rejectTitle"></p>
|
||||
<label class="form-label">Motif (optionnel)</label>
|
||||
<textarea name="notes" class="form-control" rows="2" placeholder="Sources insuffisantes, hors sujet…"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-times me-1"></i>Confirmer le rejet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openEditModal(rec) {
|
||||
const f = document.getElementById('editForm');
|
||||
f.action = '/admin/review/' + rec.id + '/update';
|
||||
|
||||
document.getElementById('edit_domain').value = rec.domain || 'other';
|
||||
document.getElementById('edit_evidence_level').value = rec.evidence_level || 'C';
|
||||
document.getElementById('edit_study_type').value = rec.study_type || 'observational';
|
||||
document.getElementById('edit_title').value = rec.title || '';
|
||||
document.getElementById('edit_summary').value = rec.summary || '';
|
||||
document.getElementById('edit_details').value = rec.details || '';
|
||||
document.getElementById('edit_intervention').value = rec.intervention || '';
|
||||
document.getElementById('edit_effect').value = rec.effect || '';
|
||||
document.getElementById('edit_source_title').value = rec.source_title || '';
|
||||
document.getElementById('edit_source_doi').value = rec.source_doi || '';
|
||||
document.getElementById('edit_source_url').value = rec.source_url || '';
|
||||
document.getElementById('edit_priority').value = rec.priority || 5;
|
||||
document.getElementById('edit_target_sex').value = rec.target_sex || 'all';
|
||||
document.getElementById('edit_target_age_min').value = rec.target_age_min || '';
|
||||
document.getElementById('edit_target_age_max').value = rec.target_age_max || '';
|
||||
document.getElementById('edit_under18').checked = !!rec.not_for_under_18;
|
||||
document.getElementById('edit_pregnant').checked = !!rec.not_for_pregnant;
|
||||
document.getElementById('edit_admin_notes').value = rec.admin_notes || '';
|
||||
|
||||
// Reset all checkboxes first
|
||||
f.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false);
|
||||
|
||||
// Re-check required_conditions
|
||||
(rec._required_conditions ? JSON.parse(rec._required_conditions) : []).forEach(v => {
|
||||
const cb = f.querySelector(`input[name=required_conditions][value="${v}"]`);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
(rec._excluded_conditions ? JSON.parse(rec._excluded_conditions) : []).forEach(v => {
|
||||
const cb = f.querySelector(`input[name=excluded_conditions][value="${v}"]`);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
(rec._required_health_goals ? JSON.parse(rec._required_health_goals) : []).forEach(v => {
|
||||
const cb = f.querySelector(`input[name=required_health_goals][value="${v}"]`);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
(rec._required_diet_types ? JSON.parse(rec._required_diet_types) : []).forEach(v => {
|
||||
const cb = f.querySelector(`input[name=required_diet_types][value="${v}"]`);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
(rec._required_activity_levels ? JSON.parse(rec._required_activity_levels) : []).forEach(v => {
|
||||
const cb = f.querySelector(`input[name=required_activity_levels][value="${v}"]`);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
}
|
||||
|
||||
function openRejectModal(id, title) {
|
||||
document.getElementById('rejectForm').action = '/admin/review/' + id + '/reject';
|
||||
document.getElementById('rejectTitle').textContent = '« ' + title + ' »';
|
||||
new bootstrap.Modal(document.getElementById('rejectModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
313
templates/admin/sources.html
Normal file
313
templates/admin/sources.html
Normal file
@@ -0,0 +1,313 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Sources — Admin OpenCare{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
{% include 'admin/_sidebar.html' %}
|
||||
|
||||
<div class="admin-main">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-title">
|
||||
<i class="fas fa-rss me-2 text-accent"></i>Sources de crawl
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Gérez les URLs surveillées quotidiennement</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="triggerCrawl(null)">
|
||||
<i class="fas fa-sync me-1"></i>Crawler toutes les sources
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Crawl log panel ─────────────────────────────────────────── -->
|
||||
<div id="crawlStatusPanel" class="card border-0 shadow-sm rounded-3 mb-4" style="display:none">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div id="crawlStatusIcon" class="admin-stat-icon" style="background:#eff6ff;color:#2563eb">
|
||||
<i class="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<strong id="crawlStatusTitle">Crawl en cours…</strong>
|
||||
<span id="crawlStatusBadge" class="badge bg-primary">En cours</span>
|
||||
</div>
|
||||
<div class="text-muted small" id="crawlStatusMeta"></div>
|
||||
</div>
|
||||
<div id="crawlStatsCounts" class="d-flex gap-3 text-center"></div>
|
||||
</div>
|
||||
<div id="crawlLog" class="crawl-log-box">
|
||||
<span class="text-muted" style="font-size:.78rem;font-style:italic">En attente de logs…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add source form ─────────────────────────────────────────── -->
|
||||
<div class="card border-0 shadow-sm rounded-3 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-plus-circle me-2 text-accent"></i>Ajouter une source</h5>
|
||||
<form method="post" action="{{ url_for('admin_sources') }}">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-medium">Nom</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="Examine.com Nutrition" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium">URL</label>
|
||||
<input type="url" name="url" class="form-control" placeholder="https://..." required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label fw-medium">Type</label>
|
||||
<select name="source_type" class="form-select">
|
||||
<option value="rss">Flux RSS</option>
|
||||
<option value="index">Index (liens)</option>
|
||||
<option value="webpage">Page web</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label fw-medium">Catégorie</label>
|
||||
<select name="category" class="form-select">
|
||||
<option value="advice">Conseils</option>
|
||||
<option value="scientific">Scientifique</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Sources list ────────────────────────────────────────────── -->
|
||||
{% if sources %}
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>URL</th>
|
||||
<th>Type</th>
|
||||
<th>Catégorie</th>
|
||||
<th>Dernier crawl</th>
|
||||
<th>Articles</th>
|
||||
<th>Statut</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for src in sources %}
|
||||
<tr class="{% if not src.active %}table-secondary opacity-60{% endif %}">
|
||||
<td class="fw-medium">{{ src.name }}</td>
|
||||
<td>
|
||||
<a href="{{ src.url }}" target="_blank" rel="noopener"
|
||||
class="text-muted small text-truncate d-inline-block" style="max-width:260px">
|
||||
{{ src.url }}<i class="fas fa-external-link-alt ms-1 opacity-50" style="font-size:.65rem"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if src.source_type == 'rss' %}
|
||||
<span class="badge bg-info text-dark"><i class="fas fa-rss me-1"></i>RSS</span>
|
||||
{% elif src.source_type == 'index' %}
|
||||
<span class="badge bg-warning text-dark"><i class="fas fa-sitemap me-1"></i>Index</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary"><i class="fas fa-globe me-1"></i>Webpage</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if src.category == 'scientific' %}
|
||||
<span class="badge bg-primary"><i class="fas fa-flask me-1"></i>Scientifique</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success"><i class="fas fa-lightbulb me-1"></i>Conseils</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small" id="lastCrawl_{{ src.id }}">
|
||||
{{ src.last_crawled_at.strftime('%d/%m/%Y %H:%M') if src.last_crawled_at else '—' }}
|
||||
</td>
|
||||
<td class="text-muted">{{ src.articles_found or 0 }}</td>
|
||||
<td>
|
||||
{% if src.active %}
|
||||
<span class="badge bg-success">Actif</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactif</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<!-- AJAX crawl button -->
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
title="Crawler maintenant" onclick="triggerCrawl({{ src.id }})">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<form action="{{ url_for('admin_source_toggle', sid=src.id) }}" method="post" class="d-inline">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {% if src.active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if src.active %}Désactiver{% else %}Activer{% endif %}">
|
||||
<i class="fas fa-{% if src.active %}pause{% else %}play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ url_for('admin_source_delete', sid=src.id) }}" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Supprimer « {{ src.name }} » ?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-rss fa-3x mb-3 opacity-25"></i>
|
||||
<p class="mb-0">Aucune source configurée. Ajoutez une URL ci-dessus.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const CRAWL_RUN_URL = '{{ url_for("admin_crawl_run") }}';
|
||||
const CRAWL_STAT_URL = '{{ url_for("admin_crawl_status") }}';
|
||||
|
||||
const panel = document.getElementById('crawlStatusPanel');
|
||||
const logBox = document.getElementById('crawlLog');
|
||||
const icon = document.getElementById('crawlStatusIcon');
|
||||
const ttl = document.getElementById('crawlStatusTitle');
|
||||
const badge = document.getElementById('crawlStatusBadge');
|
||||
const meta = document.getElementById('crawlStatusMeta');
|
||||
const counts = document.getElementById('crawlStatsCounts');
|
||||
|
||||
let pollTimer = null;
|
||||
let lastLogLen = 0;
|
||||
let triggeredAt = 0; // timestamp of last crawl trigger
|
||||
|
||||
// ── Trigger crawl via AJAX (no page reload) ───────────────────────
|
||||
async function triggerCrawl(sourceId) {
|
||||
triggeredAt = Date.now();
|
||||
lastLogLen = 0;
|
||||
logBox.innerHTML = '<span style="color:#64748b;font-style:italic">Démarrage du crawl…</span>';
|
||||
showRunning('Démarrage…');
|
||||
panel.style.display = 'block';
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
||||
const body = new FormData();
|
||||
if (sourceId) body.append('source_id', sourceId);
|
||||
|
||||
try {
|
||||
await fetch(CRAWL_RUN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body,
|
||||
});
|
||||
} catch(e) {
|
||||
appendLog('❌ Impossible de contacter le serveur : ' + e, 'log-err');
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pollTimer);
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────
|
||||
function schedulePoll() {
|
||||
// Grace window: poll every 400ms for 15s after trigger (thread may not have started yet)
|
||||
const inGrace = (Date.now() - triggeredAt) < 15000;
|
||||
const delay = inGrace ? 400 : 1500;
|
||||
pollTimer = setTimeout(poll, delay);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch(CRAWL_STAT_URL);
|
||||
const data = await r.json();
|
||||
renderStatus(data);
|
||||
|
||||
const inGrace = (Date.now() - triggeredAt) < 15000;
|
||||
if (data.status === 'running' || (data.status === 'idle' && inGrace)) {
|
||||
schedulePoll();
|
||||
} else if (data.status !== 'idle') {
|
||||
// done or error — one final render, stop polling
|
||||
pollTimer = null;
|
||||
} else {
|
||||
pollTimer = null;
|
||||
}
|
||||
} catch(e) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────
|
||||
function showRunning(text) {
|
||||
icon.innerHTML = '<i class="fas fa-sync fa-spin"></i>';
|
||||
icon.style.cssText = 'background:#eff6ff;color:#2563eb';
|
||||
ttl.textContent = text;
|
||||
badge.textContent = 'En cours'; badge.className = 'badge bg-primary';
|
||||
}
|
||||
|
||||
function renderStatus(data) {
|
||||
if (!data || data.status === 'idle') return;
|
||||
|
||||
panel.style.display = 'block';
|
||||
|
||||
if (data.status === 'running') {
|
||||
showRunning('Crawl en cours…');
|
||||
} else if (data.status === 'done') {
|
||||
icon.innerHTML = '<i class="fas fa-check-circle"></i>';
|
||||
icon.style.cssText = 'background:#f0fdf4;color:#166534';
|
||||
ttl.textContent = 'Crawl terminé';
|
||||
badge.textContent = 'Terminé'; badge.className = 'badge bg-success';
|
||||
} else {
|
||||
icon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
|
||||
icon.style.cssText = 'background:#fef2f2;color:#991b1b';
|
||||
ttl.textContent = 'Erreur lors du crawl';
|
||||
badge.textContent = 'Erreur'; badge.className = 'badge bg-danger';
|
||||
}
|
||||
|
||||
let metaTxt = '';
|
||||
if (data.started_at) metaTxt = 'Démarré à ' + data.started_at;
|
||||
if (data.ended_at) metaTxt += ' · Terminé à ' + data.ended_at;
|
||||
meta.textContent = metaTxt;
|
||||
|
||||
if (data.articles_processed > 0 || data.status !== 'running') {
|
||||
counts.innerHTML =
|
||||
`<div><div class="fw-bold fs-5">${data.articles_processed}</div><div class="small text-muted">articles</div></div>` +
|
||||
`<div><div class="fw-bold fs-5">${data.recs_generated}</div><div class="small text-muted">recs</div></div>`;
|
||||
}
|
||||
|
||||
// Append only new log lines
|
||||
const lines = data.log || [];
|
||||
if (lines.length > lastLogLen) {
|
||||
if (lastLogLen === 0) logBox.innerHTML = '';
|
||||
lines.slice(lastLogLen).forEach(line => appendLog(line));
|
||||
lastLogLen = lines.length;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(line, extraClass) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'crawl-log-line';
|
||||
if (extraClass) div.classList.add(extraClass);
|
||||
else if (/✅|✓|🏁/.test(line)) div.classList.add('log-ok');
|
||||
else if (/⚠|❌|💥/.test(line)) div.classList.add('log-err');
|
||||
else if (/🤖/.test(line)) div.classList.add('log-ai');
|
||||
div.textContent = line;
|
||||
logBox.appendChild(div);
|
||||
}
|
||||
|
||||
// ── On page load: restore last run if any ────────────────────────
|
||||
(async () => {
|
||||
const r = await fetch(CRAWL_STAT_URL);
|
||||
const data = await r.json();
|
||||
if (data.status && data.status !== 'idle') {
|
||||
renderStatus(data);
|
||||
if (data.status === 'running') poll();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
88
templates/base.html
Normal file
88
templates/base.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}OpenCare{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark oc-nav">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold fs-4" href="{{ url_for('index') }}">
|
||||
<i class="fas fa-heartbeat me-2 text-accent"></i>Open<span class="text-accent">Care</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-1">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('dashboard') }}">
|
||||
<i class="fas fa-chart-line me-1"></i>Tableau de bord
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('profile') }}">
|
||||
<i class="fas fa-user-edit me-1"></i>Mon profil
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin_index') }}">
|
||||
<i class="fas fa-shield-alt me-1"></i>Admin
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item ms-2">
|
||||
<a class="nav-link btn btn-outline-light btn-sm px-3" href="{{ url_for('logout') }}">
|
||||
<i class="fas fa-sign-out-alt me-1"></i>Déconnexion
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('login') }}">Connexion</a>
|
||||
</li>
|
||||
<li class="nav-item ms-2">
|
||||
<a class="nav-link btn btn-accent btn-sm px-3" href="{{ url_for('register') }}">
|
||||
Créer un compte
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="container mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show rounded-3 shadow-sm" role="alert">
|
||||
<i class="fas fa-{% if cat == 'success' %}check-circle{% elif cat == 'danger' %}exclamation-circle{% elif cat == 'warning' %}exclamation-triangle{% else %}info-circle{% endif %} me-2"></i>
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="oc-footer mt-5 py-4">
|
||||
<div class="container text-center">
|
||||
<p class="mb-1 fw-semibold"><i class="fas fa-heartbeat me-2 text-accent"></i>OpenCare — Santé personnalisée basée sur les preuves</p>
|
||||
<small class="text-muted">Les informations sont à titre éducatif uniquement et ne remplacent pas un avis médical professionnel.</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
183
templates/dashboard.html
Normal file
183
templates/dashboard.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tableau de bord — OpenCare{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="row align-items-center mb-4">
|
||||
<div class="col">
|
||||
<h1 class="fw-bold h3 mb-1">
|
||||
<i class="fas fa-chart-line text-primary me-2"></i>
|
||||
Bonjour {% if profile.first_name %}<span class="text-primary">{{ profile.first_name }}</span>{% else %}!{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{{ total }} recommandations personnalisées pour votre profil</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-user-edit me-2"></i>Modifier le profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile summary bar -->
|
||||
<div class="profile-summary-bar card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="avatar-circle">
|
||||
{{ (profile.first_name or 'U')[0].upper() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ profile.first_name or '' }} {{ profile.last_name or '' }}</div>
|
||||
<div class="text-muted small">{{ current_user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if profile.age %}
|
||||
<span class="profile-badge"><i class="fas fa-birthday-cake me-1"></i>{{ profile.age }} ans</span>
|
||||
{% endif %}
|
||||
{% if profile.bmi %}
|
||||
<span class="profile-badge profile-badge-{{ profile.bmi_category[1] }}">
|
||||
<i class="fas fa-weight me-1"></i>IMC {{ profile.bmi }} — {{ profile.bmi_category[0] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if profile.blood_type %}
|
||||
<span class="profile-badge"><i class="fas fa-tint me-1"></i>Groupe {{ profile.blood_type }}</span>
|
||||
{% endif %}
|
||||
{% if profile.diet_type %}
|
||||
<span class="profile-badge">
|
||||
{% set diet_icons = {'omnivore':'🥩','flexitarian':'🥗','vegetarian':'🌿','vegan':'🌱','keto':'🥑','paleo':'🍖','mediterranean':'🫒'} %}
|
||||
{{ diet_icons.get(profile.diet_type, '🍽️') }}
|
||||
{{ {'omnivore':'Omnivore','flexitarian':'Flexitarien','vegetarian':'Végétarien','vegan':'Vegan','keto':'Keto','paleo':'Paléo','mediterranean':'Méditerranéen'}.get(profile.diet_type, profile.diet_type) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if profile.activity_level %}
|
||||
<span class="profile-badge">
|
||||
{% set act_icons = {'sedentary':'🪑','light':'🚶','moderate':'🚴','intense':'🏋️','athlete':'🏆'} %}
|
||||
{{ act_icons.get(profile.activity_level, '🏃') }}
|
||||
{{ {'sedentary':'Sédentaire','light':'Actif léger','moderate':'Actif modéré','intense':'Très actif','athlete':'Athlète'}.get(profile.activity_level, profile.activity_level) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="completeness-wrap">
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Profil complété</span>
|
||||
<span class="fw-semibold text-primary">{{ completeness }}%</span>
|
||||
</div>
|
||||
<div class="progress completeness-bar">
|
||||
<div class="progress-bar bg-primary" style="width: {{ completeness }}%"></div>
|
||||
</div>
|
||||
{% if completeness < 70 %}
|
||||
<a href="{{ url_for('profile') }}" class="small text-primary mt-1 d-inline-block">
|
||||
<i class="fas fa-plus me-1"></i>Compléter pour plus de recommandations
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="d-flex flex-wrap gap-3 mb-4 align-items-center">
|
||||
<span class="text-muted small fw-semibold">Niveau de preuve :</span>
|
||||
<span class="evidence-badge ev-A">A — Fort (méta-analyse / RCT)</span>
|
||||
<span class="evidence-badge ev-B">B — Modéré (observationnel)</span>
|
||||
<span class="evidence-badge ev-C">C — Expert consensus</span>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation domains -->
|
||||
{% for domain_key, (domain_label, domain_icon), recs in ordered_domains %}
|
||||
<div class="domain-section mb-5">
|
||||
<div class="domain-header d-flex align-items-center mb-3">
|
||||
<span class="domain-icon-lg me-3">{{ domain_icon }}</span>
|
||||
<div>
|
||||
<h4 class="fw-bold mb-0">{{ domain_label }}</h4>
|
||||
<span class="text-muted small">{{ recs|length }} recommandation{{ 's' if recs|length > 1 else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for rec in recs %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="rec-card card h-100 border-0 shadow-sm" data-bs-toggle="modal" data-bs-target="#modal-{{ rec.id }}">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="rec-icon">{{ rec.icon }}</span>
|
||||
<span class="evidence-badge ev-{{ rec.evidence_level }}">{{ rec.evidence_level }}</span>
|
||||
</div>
|
||||
<h6 class="fw-bold mb-2">{{ rec.title }}</h6>
|
||||
<p class="text-muted small mb-3 rec-summary">{{ rec.summary }}</p>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for tag in rec.tags[:3] %}
|
||||
<span class="tag-pill">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0 px-3 pb-3">
|
||||
<button class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="fas fa-book-open me-1"></i>Voir les détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for this rec -->
|
||||
<div class="modal fade" id="modal-{{ rec.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="rec-icon-lg">{{ rec.icon }}</span>
|
||||
<div>
|
||||
<h5 class="modal-title fw-bold mb-1">{{ rec.title }}</h5>
|
||||
<span class="evidence-badge ev-{{ rec.evidence_level }} me-2">Niveau {{ rec.evidence_level }}</span>
|
||||
<span class="badge bg-light text-dark">{{ rec.domain_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-3">
|
||||
<div class="rec-detail-box mb-3">
|
||||
<p class="mb-0">{{ rec.details }}</p>
|
||||
</div>
|
||||
<div class="source-box">
|
||||
<i class="fas fa-graduation-cap text-primary me-2"></i>
|
||||
<strong>Source :</strong> {{ rec.source }}
|
||||
</div>
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
{% for tag in rec.tags %}
|
||||
<span class="tag-pill tag-pill-lg">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not ordered_domains %}
|
||||
<div class="empty-state text-center py-5">
|
||||
<div class="empty-icon mb-4">🔍</div>
|
||||
<h4 class="fw-bold">Aucune recommandation pour l'instant</h4>
|
||||
<p class="text-muted mb-4">Complétez votre profil de santé pour recevoir des conseils personnalisés.</p>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-primary btn-lg px-5">
|
||||
<i class="fas fa-user-edit me-2"></i>Compléter mon profil
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
136
templates/index.html
Normal file
136
templates/index.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}OpenCare — Santé personnalisée basée sur les preuves{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="hero-section py-5">
|
||||
<div class="container py-3">
|
||||
<div class="row align-items-center gy-5">
|
||||
<div class="col-lg-6">
|
||||
<span class="badge rounded-pill bg-accent-light text-accent mb-3 px-3 py-2">
|
||||
<i class="fas fa-microscope me-1"></i>Evidence-based medicine
|
||||
</span>
|
||||
<h1 class="display-4 fw-bold lh-sm mb-4">
|
||||
Votre santé,<br><span class="text-gradient">vraiment personnalisée</span>
|
||||
</h1>
|
||||
<p class="lead text-muted mb-4">
|
||||
OpenCare synthétise des milliers d'études scientifiques et les traduit en recommandations concrètes, adaptées à <strong>votre profil unique</strong> : âge, génétique, mode de vie et objectifs.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<a href="{{ url_for('register') }}" class="btn btn-primary btn-lg px-4 shadow">
|
||||
<i class="fas fa-rocket me-2"></i>Commencer gratuitement
|
||||
</a>
|
||||
<a href="#domains" class="btn btn-outline-secondary btn-lg px-4">
|
||||
Découvrir les domaines
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 d-flex flex-wrap gap-4 text-muted small">
|
||||
<span><i class="fas fa-lock me-1 text-success"></i>Données locales</span>
|
||||
<span><i class="fas fa-flask me-1 text-primary"></i>Sources PubMed</span>
|
||||
<span><i class="fas fa-user-md me-1 text-warning"></i>Pas de diagnostic</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="hero-visual d-flex flex-wrap gap-3 justify-content-center">
|
||||
{% for item in [
|
||||
('🧬', 'Génétique & origines'),
|
||||
('🥗', 'Nutrition ciblée'),
|
||||
('⏱️', 'Jeûne intermittent'),
|
||||
('💪', 'Exercice optimal'),
|
||||
('😴', 'Sommeil réparateur'),
|
||||
('🦠', 'Microbiote'),
|
||||
] %}
|
||||
<div class="hero-pill">
|
||||
<span class="hero-pill-icon">{{ item[0] }}</span>
|
||||
<span class="hero-pill-text">{{ item[1] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-bar py-4 bg-white border-top border-bottom">
|
||||
<div class="container">
|
||||
<div class="row text-center g-4">
|
||||
<div class="col-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num text-primary">25+</div>
|
||||
<div class="stat-lbl">Recommandations</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num text-primary">6</div>
|
||||
<div class="stat-lbl">Domaines couverts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-num text-primary">100%</div>
|
||||
<div class="stat-lbl">Sources citées</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="domains" class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold">Domaines de santé couverts</h2>
|
||||
<p class="text-muted">Chaque recommandation est filtrée selon votre profil et sourcée scientifiquement.</p>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
{% for icon, title, desc, color in [
|
||||
('🥗', 'Alimentation', 'Régimes, macronutriments, aliments fonctionnels, intolérances alimentaires.', 'green'),
|
||||
('⏱️', 'Jeûne intermittent', 'Protocoles 14/10, 16/8, 5:2 — adaptés à votre activité et objectifs.', 'teal'),
|
||||
('💪', 'Exercice', 'Cardio zone 2, musculation, HIIT, mobilité — selon votre niveau actuel.', 'blue'),
|
||||
('😴', 'Sommeil', 'Durée, hygiène du sommeil, chronotype, gestion du stress nocturne.', 'indigo'),
|
||||
('💚', 'Gestion du stress', 'Cohérence cardiaque, méditation MBSR, techniques validées cliniquement.', 'emerald'),
|
||||
('💊', 'Suppléments', 'Vitamine D, oméga-3, magnésium, probiotiques — selon vos déficits réels.', 'amber'),
|
||||
] %}
|
||||
<div class="col-md-4">
|
||||
<div class="domain-card card h-100 border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="domain-icon mb-3">{{ icon }}</div>
|
||||
<h5 class="fw-bold mb-2">{{ title }}</h5>
|
||||
<p class="text-muted small mb-0">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="how-it-works py-5 bg-light">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold">Comment ça fonctionne</h2>
|
||||
</div>
|
||||
<div class="row g-4 text-center">
|
||||
{% for n, icon, title, desc in [
|
||||
(1, 'fas fa-user-plus', 'Créez votre compte', 'Inscription simple et rapide, données stockées localement.'),
|
||||
(2, 'fas fa-clipboard-list', 'Remplissez votre profil', 'Âge, poids, antécédents, mode de vie, objectifs — 7 sections.'),
|
||||
(3, 'fas fa-brain', 'Recevez vos recommandations', 'Le moteur croise vos données avec les publications scientifiques.'),
|
||||
] %}
|
||||
<div class="col-md-4">
|
||||
<div class="step-card">
|
||||
<div class="step-num">{{ n }}</div>
|
||||
<i class="{{ icon }} fa-2x text-primary mb-3"></i>
|
||||
<h5 class="fw-bold">{{ title }}</h5>
|
||||
<p class="text-muted small">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-center mt-5">
|
||||
<a href="{{ url_for('register') }}" class="btn btn-primary btn-lg px-5">
|
||||
<i class="fas fa-arrow-right me-2"></i>Commencer maintenant
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
51
templates/login.html
Normal file
51
templates/login.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Connexion — OpenCare{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="auth-card card border-0 shadow">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="text-center mb-4">
|
||||
<div class="auth-icon-wrap mb-3">
|
||||
<i class="fas fa-heartbeat text-primary fa-2x"></i>
|
||||
</div>
|
||||
<h2 class="h4 fw-bold">Connexion</h2>
|
||||
<p class="text-muted small">Accédez à vos recommandations personnalisées</p>
|
||||
</div>
|
||||
<form method="POST" novalidate>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-envelope text-muted"></i>
|
||||
</span>
|
||||
<input type="email" name="email" class="form-control border-start-0 ps-0"
|
||||
placeholder="votre@email.com" required autocomplete="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Mot de passe</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-lock text-muted"></i>
|
||||
</span>
|
||||
<input type="password" name="password" class="form-control border-start-0 ps-0"
|
||||
placeholder="••••••••" required autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 btn-lg fw-semibold">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<hr class="my-4">
|
||||
<p class="text-center text-muted small mb-0">
|
||||
Pas encore de compte ?
|
||||
<a href="{{ url_for('register') }}" class="fw-semibold">Créer un compte</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
438
templates/profile.html
Normal file
438
templates/profile.html
Normal file
@@ -0,0 +1,438 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mon profil de santé — OpenCare{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="fw-bold h3 mb-1"><i class="fas fa-user-edit text-primary me-2"></i>Mon profil de santé</h1>
|
||||
<p class="text-muted">Plus votre profil est complet, plus les recommandations seront précises.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="profileForm">
|
||||
<!-- Tab Navigation -->
|
||||
<ul class="nav nav-pills profile-tabs mb-4 flex-wrap gap-1" id="profileTabs" role="tablist">
|
||||
{% for tab_id, icon, label in [
|
||||
('identite', 'fas fa-id-card', 'Identité'),
|
||||
('origine', 'fas fa-globe', 'Origine & Biologie'),
|
||||
('sante', 'fas fa-notes-medical', 'Antécédents'),
|
||||
('lifestyle', 'fas fa-sun', 'Mode de vie'),
|
||||
('alimentation', 'fas fa-utensils', 'Alimentation'),
|
||||
('activite', 'fas fa-running', 'Activité physique'),
|
||||
('objectifs', 'fas fa-bullseye', 'Objectifs'),
|
||||
] %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if loop.first %}active{% endif %} profile-tab-btn"
|
||||
id="tab-{{ tab_id }}" data-bs-toggle="tab"
|
||||
data-bs-target="#pane-{{ tab_id }}" type="button">
|
||||
<i class="{{ icon }} me-1"></i>{{ label }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content profile-tab-content">
|
||||
|
||||
<!-- ① IDENTITÉ -->
|
||||
<div class="tab-pane fade show active" id="pane-identite">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-id-card me-2 text-primary"></i>Informations personnelles</h5>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Prénom</label>
|
||||
<input type="text" name="first_name" class="form-control"
|
||||
value="{{ profile.first_name or '' }}" placeholder="Votre prénom">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Nom</label>
|
||||
<input type="text" name="last_name" class="form-control"
|
||||
value="{{ profile.last_name or '' }}" placeholder="Votre nom">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Année de naissance</label>
|
||||
<input type="number" name="birth_year" class="form-control"
|
||||
min="1920" max="2015"
|
||||
value="{{ profile.birth_year or '' }}" placeholder="Ex: 1985">
|
||||
{% if profile.age %}
|
||||
<div class="form-text text-success"><i class="fas fa-check me-1"></i>{{ profile.age }} ans</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Sexe biologique</label>
|
||||
<select name="sex" class="form-select">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for val, lbl in [('male','Homme'),('female','Femme'),('other','Autre / Non binaire')] %}
|
||||
<option value="{{ val }}" {% if profile.sex == val %}selected{% endif %}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Taille (cm)</label>
|
||||
<input type="number" name="height_cm" class="form-control"
|
||||
min="100" max="250" step="0.5"
|
||||
value="{{ profile.height_cm or '' }}" placeholder="Ex: 175"
|
||||
id="heightInput">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Poids (kg)</label>
|
||||
<input type="number" name="weight_kg" class="form-control"
|
||||
min="30" max="300" step="0.1"
|
||||
value="{{ profile.weight_kg or '' }}" placeholder="Ex: 70"
|
||||
id="weightInput">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">IMC calculé</label>
|
||||
<div class="form-control bg-light fw-semibold" id="bmiDisplay">
|
||||
{% if profile.bmi %}
|
||||
<span class="text-{{ profile.bmi_category[1] }}">{{ profile.bmi }} — {{ profile.bmi_category[0] }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">— (remplissez taille & poids)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② ORIGINE & BIOLOGIE -->
|
||||
<div class="tab-pane fade" id="pane-origine">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-globe me-2 text-primary"></i>Origine & Biologie</h5>
|
||||
<p class="text-muted small mb-4">Ces données permettent d'adapter les recommandations aux prédispositions génétiques et culturelles associées aux origines.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Origine ethnique</label>
|
||||
<select name="ethnicity" class="form-select">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for val, lbl in [
|
||||
('european', 'Européen / Caucasien'),
|
||||
('african', 'Africain / Afro-descendant'),
|
||||
('asian_east', 'Asiatique de l\'Est (Chine, Japon, Corée)'),
|
||||
('asian_south', 'Asiatique du Sud (Inde, Pakistan)'),
|
||||
('latin', 'Latino-américain'),
|
||||
('middle_eastern', 'Moyen-Oriental / Maghrébin'),
|
||||
('mixed', 'Métis / Origines multiples'),
|
||||
('other', 'Autre'),
|
||||
] %}
|
||||
<option value="{{ val }}" {% if profile.ethnicity == val %}selected{% endif %}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Pays d'origine</label>
|
||||
<input type="text" name="country_origin" class="form-control"
|
||||
value="{{ profile.country_origin or '' }}" placeholder="Ex: France, Sénégal, Maroc...">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Groupe sanguin</label>
|
||||
<select name="blood_type" class="form-select">
|
||||
<option value="">— Inconnu —</option>
|
||||
{% for bt in ['A+','A-','B+','B-','AB+','AB-','O+','O-'] %}
|
||||
<option value="{{ bt }}" {% if profile.blood_type == bt %}selected{% endif %}>{{ bt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="info-box mt-2">
|
||||
<i class="fas fa-info-circle text-primary me-2"></i>
|
||||
<strong>Pourquoi ces données ?</strong> Certaines populations présentent des prédispositions documentées : intolérance au lactose plus fréquente en Asie de l'Est (~90%), risque de diabète T2 plus élevé en Asie du Sud à IMC plus bas, etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ ANTÉCÉDENTS -->
|
||||
<div class="tab-pane fade" id="pane-sante">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-notes-medical me-2 text-primary"></i>Antécédents de santé</h5>
|
||||
<p class="text-muted small mb-4">Cochez les conditions qui vous concernent ou qui ont des antécédents familiaux directs.</p>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for val, lbl in [
|
||||
('hypertension', '🫀 Hypertension artérielle'),
|
||||
('diabetes_risk', '🍬 Risque / prédiabète T2'),
|
||||
('cardiovascular_risk', '❤️ Risque cardiovasculaire'),
|
||||
('anxiety', '🧠 Anxiété / dépression'),
|
||||
('digestive_issues', '🫃 Troubles digestifs / SII'),
|
||||
('back_pain', '🦵 Douleurs dorsales / lombalgies'),
|
||||
('osteoporosis_risk', '🦴 Risque d\'ostéoporose'),
|
||||
('metabolic_syndrome', '⚖️ Syndrome métabolique'),
|
||||
('thyroid_issues', '🦋 Troubles thyroïdiens'),
|
||||
('sleep_apnea', '😮 Apnée du sommeil'),
|
||||
] %}
|
||||
<div class="col-md-6">
|
||||
<div class="form-check condition-check">
|
||||
<input class="form-check-input" type="checkbox" name="conditions"
|
||||
value="{{ val }}" id="cond_{{ val }}"
|
||||
{% if val in (profile.conditions or []) %}checked{% endif %}>
|
||||
<label class="form-check-label" for="cond_{{ val }}">{{ lbl }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label fw-semibold">Allergies / médicaments (optionnel)</label>
|
||||
<textarea name="allergies_text" class="form-control" rows="3"
|
||||
placeholder="Ex: allergie aux arachides, prise de metformine, antihypertenseurs...">{{ profile._allergies_text or '' }}</textarea>
|
||||
<div class="form-text">Ces informations restent locales et ne sont pas transmises.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ④ MODE DE VIE -->
|
||||
<div class="tab-pane fade" id="pane-lifestyle">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-sun me-2 text-primary"></i>Mode de vie</h5>
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
Durée de sommeil moyenne
|
||||
<span class="badge bg-light text-dark ms-2" id="sleepVal">
|
||||
{{ '%.1f'|format(profile.sleep_hours) if profile.sleep_hours else '7.0' }}h
|
||||
</span>
|
||||
</label>
|
||||
<input type="range" name="sleep_hours" class="form-range" min="3" max="12" step="0.5"
|
||||
value="{{ profile.sleep_hours or 7 }}"
|
||||
oninput="document.getElementById('sleepVal').textContent = this.value + 'h'">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span>3h</span><span>7.5h (optimal)</span><span>12h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
Niveau de stress
|
||||
<span class="badge bg-light text-dark ms-2" id="stressVal">
|
||||
{{ profile.stress_level or 5 }}/10
|
||||
</span>
|
||||
</label>
|
||||
<input type="range" name="stress_level" class="form-range stress-range" min="1" max="10"
|
||||
value="{{ profile.stress_level or 5 }}"
|
||||
oninput="document.getElementById('stressVal').textContent = this.value + '/10'">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span>😌 Faible</span><span>😰 Élevé</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tabac</label>
|
||||
<select name="smoking" class="form-select">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for val, lbl in [('never','Non-fumeur'),('former','Ex-fumeur'),('occasional','Fumeur occasionnel'),('current','Fumeur régulier')] %}
|
||||
<option value="{{ val }}" {% if profile.smoking == val %}selected{% endif %}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Consommation d'alcool</label>
|
||||
<select name="alcohol_freq" class="form-select">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for val, lbl in [('never','Jamais'),('occasional','Occasionnel (<1x/sem)'),('moderate','Modéré (1-3x/sem)'),('frequent','Fréquent (>3x/sem)')] %}
|
||||
<option value="{{ val }}" {% if profile.alcohol_freq == val %}selected{% endif %}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ ALIMENTATION -->
|
||||
<div class="tab-pane fade" id="pane-alimentation">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-utensils me-2 text-primary"></i>Alimentation</h5>
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Type de régime alimentaire</label>
|
||||
<select name="diet_type" class="form-select">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{% for val, lbl in [
|
||||
('omnivore', '🥩 Omnivore'),
|
||||
('flexitarian', '🥗 Flexitarien'),
|
||||
('vegetarian', '🌿 Végétarien'),
|
||||
('vegan', '🌱 Végétalien / Vegan'),
|
||||
('keto', '🥑 Cétogène / Low-carb'),
|
||||
('paleo', '🍖 Paléo'),
|
||||
('mediterranean', '🫒 Méditerranéen'),
|
||||
] %}
|
||||
<option value="{{ val }}" {% if profile.diet_type == val %}selected{% endif %}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Intolérances / exclusions alimentaires</label>
|
||||
<div class="row g-2 mt-1">
|
||||
{% for val, lbl in [
|
||||
('gluten', '🌾 Gluten'),
|
||||
('lactose', '🥛 Lactose'),
|
||||
('fructose', '🍎 Fructose'),
|
||||
('nuts', '🥜 Fruits à coque'),
|
||||
('shellfish', '🦐 Crustacés'),
|
||||
('eggs', '🥚 Œufs'),
|
||||
('soy', '🌿 Soja'),
|
||||
('nightshades', '🍅 Solanacées'),
|
||||
] %}
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="form-check intol-check">
|
||||
<input class="form-check-input" type="checkbox" name="food_intolerances"
|
||||
value="{{ val }}" id="intol_{{ val }}"
|
||||
{% if val in (profile.food_intolerances or []) %}checked{% endif %}>
|
||||
<label class="form-check-label" for="intol_{{ val }}">{{ lbl }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑥ ACTIVITÉ PHYSIQUE -->
|
||||
<div class="tab-pane fade" id="pane-activite">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-running me-2 text-primary"></i>Activité physique</h5>
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Niveau d'activité global</label>
|
||||
<div class="row g-2 mt-1">
|
||||
{% for val, icon, lbl, desc in [
|
||||
('sedentary', '🪑', 'Sédentaire', 'Peu ou pas d\'exercice, travail de bureau'),
|
||||
('light', '🚶', 'Léger', '1-3 fois/semaine, activité légère'),
|
||||
('moderate', '🚴', 'Modéré', '3-5 fois/semaine, effort modéré'),
|
||||
('intense', '🏋️', 'Intense', '5-6 fois/semaine, effort soutenu'),
|
||||
('athlete', '🏆', 'Athlète', 'Entraînement quotidien ou compétition'),
|
||||
] %}
|
||||
<div class="col-md-4 col-6">
|
||||
<label class="activity-level-card {% if profile.activity_level == val %}selected{% endif %}">
|
||||
<input type="radio" name="activity_level" value="{{ val }}"
|
||||
{% if profile.activity_level == val %}checked{% endif %}>
|
||||
<div class="act-icon">{{ icon }}</div>
|
||||
<div class="act-label fw-semibold">{{ lbl }}</div>
|
||||
<div class="act-desc text-muted small">{{ desc }}</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Types d'activités pratiquées</label>
|
||||
<div class="row g-2 mt-1">
|
||||
{% for val, lbl in [
|
||||
('running', '🏃 Course à pied'),
|
||||
('cycling', '🚴 Vélo'),
|
||||
('swimming', '🏊 Natation'),
|
||||
('gym', '🏋️ Musculation/Salle'),
|
||||
('yoga', '🧘 Yoga / Pilates'),
|
||||
('team_sports', '⚽ Sports collectifs'),
|
||||
('martial_arts', '🥋 Arts martiaux'),
|
||||
('hiking', '🥾 Randonnée'),
|
||||
('crossfit', '🔥 CrossFit / Functional'),
|
||||
] %}
|
||||
<div class="col-md-4 col-6">
|
||||
<div class="form-check activity-check">
|
||||
<input class="form-check-input" type="checkbox" name="activity_types"
|
||||
value="{{ val }}" id="act_{{ val }}"
|
||||
{% if val in (profile.activity_types or []) %}checked{% endif %}>
|
||||
<label class="form-check-label" for="act_{{ val }}">{{ lbl }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑦ OBJECTIFS -->
|
||||
<div class="tab-pane fade" id="pane-objectifs">
|
||||
<div class="profile-section card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="section-title"><i class="fas fa-bullseye me-2 text-primary"></i>Objectifs de santé</h5>
|
||||
<p class="text-muted small mb-4">Sélectionnez vos objectifs principaux. Les recommandations seront filtrées et priorisées en conséquence.</p>
|
||||
<div class="row g-3">
|
||||
{% for val, icon, lbl, desc in [
|
||||
('weight_loss', '⚖️', 'Perte de poids', 'Réduire la masse grasse de façon durable'),
|
||||
('muscle_gain', '💪', 'Prise de muscle', 'Augmenter la masse musculaire'),
|
||||
('longevity', '🌿', 'Longévité', 'Vieillir en bonne santé'),
|
||||
('energy', '⚡', 'Plus d\'énergie', 'Réduire la fatigue chronique'),
|
||||
('sleep_quality', '😴', 'Sommeil', 'Améliorer la qualité et durée'),
|
||||
('stress_reduction', '🧘', 'Réduire le stress', 'Anxiété, bien-être mental'),
|
||||
('metabolic_health', '🔬', 'Santé métabolique', 'Glycémie, insuline, lipides'),
|
||||
('cardiovascular_health', '❤️', 'Santé cardio', 'Réduire les facteurs de risque'),
|
||||
('cognitive_performance', '🧠', 'Performance cognitive', 'Mémoire, concentration, focus'),
|
||||
] %}
|
||||
<div class="col-md-4">
|
||||
<label class="goal-card {% if val in (profile.health_goals or []) %}selected{% endif %}">
|
||||
<input type="checkbox" name="health_goals" value="{{ val }}"
|
||||
{% if val in (profile.health_goals or []) %}checked{% endif %}>
|
||||
<div class="goal-icon">{{ icon }}</div>
|
||||
<div class="goal-title fw-semibold">{{ lbl }}</div>
|
||||
<div class="goal-desc text-muted small">{{ desc }}</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end tab-content -->
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Retour au tableau de bord
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5 fw-semibold">
|
||||
<i class="fas fa-save me-2"></i>Sauvegarder mon profil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// BMI auto-calculation
|
||||
function calcBMI() {
|
||||
const h = parseFloat(document.getElementById('heightInput').value);
|
||||
const w = parseFloat(document.getElementById('weightInput').value);
|
||||
const el = document.getElementById('bmiDisplay');
|
||||
if (h > 0 && w > 0) {
|
||||
const bmi = (w / Math.pow(h/100, 2)).toFixed(1);
|
||||
let cat, cls;
|
||||
if (bmi < 18.5) { cat = 'Insuffisance pondérale'; cls = 'text-info'; }
|
||||
else if (bmi < 25) { cat = 'Poids normal'; cls = 'text-success'; }
|
||||
else if (bmi < 30) { cat = 'Surpoids'; cls = 'text-warning'; }
|
||||
else { cat = 'Obésité'; cls = 'text-danger'; }
|
||||
el.innerHTML = `<span class="${cls}">${bmi} — ${cat}</span>`;
|
||||
}
|
||||
}
|
||||
document.getElementById('heightInput').addEventListener('input', calcBMI);
|
||||
document.getElementById('weightInput').addEventListener('input', calcBMI);
|
||||
|
||||
// Activity level card selection
|
||||
document.querySelectorAll('.activity-level-card input').forEach(inp => {
|
||||
inp.addEventListener('change', () => {
|
||||
document.querySelectorAll('.activity-level-card').forEach(c => c.classList.remove('selected'));
|
||||
if (inp.checked) inp.closest('.activity-level-card').classList.add('selected');
|
||||
});
|
||||
});
|
||||
|
||||
// Goal card selection
|
||||
document.querySelectorAll('.goal-card input').forEach(inp => {
|
||||
inp.addEventListener('change', () => {
|
||||
inp.closest('.goal-card').classList.toggle('selected', inp.checked);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
65
templates/register.html
Normal file
65
templates/register.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Créer un compte — OpenCare{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="auth-card card border-0 shadow">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="text-center mb-4">
|
||||
<div class="auth-icon-wrap mb-3">
|
||||
<i class="fas fa-user-plus text-primary fa-2x"></i>
|
||||
</div>
|
||||
<h2 class="h4 fw-bold">Créer un compte</h2>
|
||||
<p class="text-muted small">Commencez à recevoir des conseils personnalisés</p>
|
||||
</div>
|
||||
<form method="POST" novalidate>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-envelope text-muted"></i>
|
||||
</span>
|
||||
<input type="email" name="email" class="form-control border-start-0 ps-0"
|
||||
placeholder="votre@email.com" required autocomplete="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Mot de passe</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-lock text-muted"></i>
|
||||
</span>
|
||||
<input type="password" name="password" class="form-control border-start-0 ps-0"
|
||||
placeholder="Min. 6 caractères" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Confirmer le mot de passe</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-lock text-muted"></i>
|
||||
</span>
|
||||
<input type="password" name="confirm_password" class="form-control border-start-0 ps-0"
|
||||
placeholder="Répétez le mot de passe" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 btn-lg fw-semibold">
|
||||
<i class="fas fa-rocket me-2"></i>Créer mon compte
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-3 p-3 bg-light rounded-3 small text-muted text-center">
|
||||
<i class="fas fa-shield-alt me-1 text-success"></i>
|
||||
Vos données sont stockées uniquement en local.
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<p class="text-center text-muted small mb-0">
|
||||
Déjà un compte ?
|
||||
<a href="{{ url_for('login') }}" class="fw-semibold">Se connecter</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user