première release

This commit is contained in:
2026-03-23 20:21:04 +01:00
commit 75c0a127bf
22 changed files with 3933 additions and 0 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
.git/
.gitignore
.env
venv/
env/
*.db

1
.env Normal file
View File

@@ -0,0 +1 @@
# VARIABLE=value #comment

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Image de base Python
FROM python:3.11-slim
# Créer un utilisateur non-root
RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser
# Définir le répertoire de travail
WORKDIR /app
# Copier le fichier des dépendances
COPY requirements.txt .
# Installer les dépendances
RUN pip install --no-cache-dir -r requirements.txt
# Copier tout le code de l'application
COPY . .
# Créer le dossier pour la base de données avec les bonnes permissions
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# Passer à l'utilisateur non-root
USER appuser
# Exposer le port 5000
EXPOSE 5000
# Variables d'environnement pour Flask
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
ENV PYTHONUNBUFFERED=1
# Commande de démarrage
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# 📅 Menu Hebdomadaire
Application web légère pour gérer vos menus de la semaine et vos recettes.
## 🚀 Installation avec Docker (RECOMMANDÉ)
### Prérequis
- Docker installé sur votre machine
- Docker Compose installé
### Étapes
1. **Télécharger tous les fichiers** dans un dossier `menu-app/`
2. **Ouvrir un terminal** dans ce dossier
3. **Lancer l'application** :
```bash
docker-compose up -d

Binary file not shown.

Binary file not shown.

215
app-version-PC.py Normal file
View File

@@ -0,0 +1,215 @@
from flask import Flask, render_template, request, redirect, url_for
import sqlite3
from datetime import datetime, timedelta
app = Flask(__name__)
# Configuration base de données
def get_connection():
conn = sqlite3.connect('menu_miam.db')
conn.row_factory = sqlite3.Row
return conn
# Initialisation base de données
def init_db():
conn = get_connection()
conn.execute('''
CREATE TABLE IF NOT EXISTS recettes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL,
ingredients TEXT,
instructions TEXT,
lien TEXT
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS menu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
jour TEXT NOT NULL,
repas TEXT NOT NULL,
recette_id INTEGER,
FOREIGN KEY (recette_id) REFERENCES recettes (id)
)
''')
conn.commit()
conn.close()
# Route principale - Menu
@app.route('/')
@app.route('/menu')
def menu():
offset = int(request.args.get('offset', 0))
# Calcul des dates
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
days = []
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
for i in range(7):
day_date = start_of_week + timedelta(days=i)
days.append({
'name': jours_fr[i],
'date': day_date.strftime('%d/%m/%Y'),
'date_obj': day_date
})
# Récupération menu
conn = get_connection()
menu_items = conn.execute('''
SELECT menu.*, recettes.nom, recettes.ingredients, recettes.lien
FROM menu
JOIN recettes ON menu.recette_id = recettes.id
WHERE date BETWEEN ? AND ?
''', (days[0]['date_obj'].isoformat(), days[6]['date_obj'].isoformat())).fetchall()
recettes = conn.execute('SELECT * FROM recettes ORDER BY nom').fetchall()
conn.close()
# Organisation menu
menu_dict = {}
for item in menu_items:
key = f"{item['jour']}_{item['repas']}"
menu_dict[key] = {
'nom': item['nom'],
'id': item['id'],
'ingredients': item['ingredients'],
'lien': item['lien']
}
return render_template('menu.html',
days=days,
menu=menu_dict,
recettes=recettes,
offset=offset,
start_date=days[0]['date'],
end_date=days[6]['date'])
# Ajouter au menu
@app.route('/add_to_menu', methods=['POST'])
def add_to_menu():
day = request.form['day']
meal = request.form['meal']
recette_id = request.form['recette_id']
offset = int(request.form.get('offset', 0))
# Calcul date
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
day_index = jours_fr.index(day)
target_date = start_of_week + timedelta(days=day_index)
conn = get_connection()
conn.execute('''
INSERT INTO menu (date, jour, repas, recette_id)
VALUES (?, ?, ?, ?)
''', (target_date.isoformat(), day, meal, recette_id))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset))
# Retirer du menu
@app.route('/remove_from_menu', methods=['POST'])
def remove_from_menu():
day = request.form['day']
meal = request.form['meal']
offset = int(request.form.get('offset', 0))
conn = get_connection()
conn.execute('DELETE FROM menu WHERE jour = ? AND repas = ?', (day, meal))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset))
# Changer de semaine
@app.route('/change_week', methods=['POST'])
def change_week():
offset = int(request.form['offset'])
return redirect(url_for('menu', offset=offset))
# 🔍 Liste recettes avec RECHERCHE
@app.route('/recettes')
def recettes():
conn = get_connection()
# Récupération du terme de recherche
search = request.args.get('search', '').strip()
# Recherche dans nom ET ingrédients
if search:
recettes = conn.execute('''
SELECT * FROM recettes
WHERE nom LIKE ? OR ingredients LIKE ?
ORDER BY nom
''', (f'%{search}%', f'%{search}%')).fetchall()
else:
recettes = conn.execute('SELECT * FROM recettes ORDER BY nom').fetchall()
conn.close()
return render_template('recettes.html', recettes=recettes, search=search)
# Ajouter recette
@app.route('/recettes/add', methods=['GET', 'POST'])
def add_recette():
if request.method == 'POST':
nom = request.form['nom']
ingredients = request.form.get('ingredients', '')
instructions = request.form.get('instructions', '')
lien = request.form.get('lien', '')
conn = get_connection()
conn.execute('''
INSERT INTO recettes (nom, ingredients, instructions, lien)
VALUES (?, ?, ?, ?)
''', (nom, ingredients, instructions, lien))
conn.commit()
conn.close()
return redirect(url_for('recettes'))
return render_template('add_recette.html')
# Modifier recette
@app.route('/recettes/edit/<int:id>', methods=['GET', 'POST'])
def edit_recette(id):
conn = get_connection()
if request.method == 'POST':
nom = request.form['nom']
ingredients = request.form.get('ingredients', '')
instructions = request.form.get('instructions', '')
lien = request.form.get('lien', '')
conn.execute('''
UPDATE recettes
SET nom = ?, ingredients = ?, instructions = ?, lien = ?
WHERE id = ?
''', (nom, ingredients, instructions, lien, id))
conn.commit()
conn.close()
return redirect(url_for('recettes'))
recette = conn.execute('SELECT * FROM recettes WHERE id = ?', (id,)).fetchone()
conn.close()
return render_template('edit_recette.html', recette=recette)
# Supprimer recette
@app.route('/recettes/delete/<int:id>', methods=['POST'])
def delete_recette(id):
conn = get_connection()
conn.execute('DELETE FROM recettes WHERE id = ?', (id,))
conn.commit()
conn.close()
return redirect(url_for('recettes'))
if __name__ == '__main__':
init_db()
app.run(debug=True, host='0.0.0.0')

741
app.py Normal file
View File

@@ -0,0 +1,741 @@
from flask import Flask, render_template, request, redirect, url_for, send_file, flash, session
from werkzeug.security import generate_password_hash, check_password_hash
from functools import wraps
import sqlite3
import os
from datetime import datetime, timedelta
import json
from io import BytesIO
app = Flask(__name__)
# ✅ SÉCURITÉ : Clé secrète depuis variable d'environnement
app.secret_key = os.environ.get('SECRET_KEY', 'changez-moi-en-production-utilisez-env')
app.config['SESSION_COOKIE_SECURE'] = False # ✅ Changé pour HTTP (mettre True avec HTTPS)
app.config['SESSION_COOKIE_HTTPONLY'] = True # Protection XSS
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Protection CSRF
# ✅ Configuration base de données compatible Docker
DB_PATH = os.path.join(os.path.dirname(__file__), 'data', 'menu_miam.db')
# ✅ CREDENTIALS depuis variables d'environnement
ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD_HASH = generate_password_hash(
os.environ.get('ADMIN_PASSWORD', 'changeme123')
)
# ========== DÉCORATEUR D'AUTHENTIFICATION ==========
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
flash('⚠️ Vous devez vous connecter pour accéder à cette page', 'warning')
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
# ========== ROUTES D'AUTHENTIFICATION ==========
@app.route('/login', methods=['GET', 'POST'])
def login():
# Si déjà connecté, rediriger vers le menu
if 'logged_in' in session:
return redirect(url_for('menu'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
if username == ADMIN_USERNAME and check_password_hash(ADMIN_PASSWORD_HASH, password):
session['logged_in'] = True
session['username'] = username
flash('✅ Connexion réussie !', 'success')
# ✅ CORRECTION : Gérer correctement la redirection
next_page = request.args.get('next')
# Vérifier que next_page est un chemin relatif valide
if next_page and next_page.startswith('/') and not next_page.startswith('//'):
return redirect(next_page)
# Sinon, rediriger vers le menu par défaut
return redirect(url_for('menu'))
else:
flash('❌ Identifiants incorrects', 'error')
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
flash('✅ Déconnexion réussie', 'success')
return redirect(url_for('login'))
# ========== CONNEXION BASE DE DONNÉES ==========
def get_connection():
"""Connexion à la base de données avec chemin persistant"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ========== INITIALISATION BASE DE DONNÉES ==========
def init_db():
"""Créer le dossier data et initialiser la base de données"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = get_connection()
# ✅ Table recettes
conn.execute('''
CREATE TABLE IF NOT EXISTS recettes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL,
ingredients TEXT,
instructions TEXT,
lien TEXT
)
''')
# ✅ Table menu
conn.execute('''
CREATE TABLE IF NOT EXISTS menu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
jour TEXT NOT NULL,
repas TEXT NOT NULL,
recette_id INTEGER,
FOREIGN KEY (recette_id) REFERENCES recettes (id),
UNIQUE(date, jour, repas)
)
''')
# ✅ Table accompagnements du menu (MODIFIÉ - ajout de la colonne date)
conn.execute('''
CREATE TABLE IF NOT EXISTS menu_accompagnements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
jour TEXT NOT NULL,
repas TEXT NOT NULL,
accompagnement_id INTEGER NOT NULL,
FOREIGN KEY (accompagnement_id) REFERENCES accompagnements (id) ON DELETE CASCADE,
UNIQUE(date, jour, repas, accompagnement_id)
)
''')
# ✅ Table accompagnements
conn.execute('''
CREATE TABLE IF NOT EXISTS accompagnements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL UNIQUE,
descriptif TEXT
)
''')
# ✅ Table de liaison recette-accompagnements (pour suggestions par défaut)
conn.execute('''
CREATE TABLE IF NOT EXISTS recette_accompagnements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recette_id INTEGER NOT NULL,
accompagnement_id INTEGER NOT NULL,
FOREIGN KEY (recette_id) REFERENCES recettes (id) ON DELETE CASCADE,
FOREIGN KEY (accompagnement_id) REFERENCES accompagnements (id) ON DELETE CASCADE,
UNIQUE(recette_id, accompagnement_id)
)
''')
# ✅ Ajouter des recettes d'exemple si la base est vide
count = conn.execute('SELECT COUNT(*) FROM recettes').fetchone()[0]
if count == 0:
recettes_exemple = [
("Pâtes Carbonara",
"Pâtes (400g), Lardons (200g), Œufs (4), Parmesan (100g), Crème fraîche (20cl)",
"1. Cuire les pâtes al dente\n2. Faire revenir les lardons\n3. Mélanger œufs, parmesan et crème\n4. Incorporer aux pâtes chaudes\n5. Servir immédiatement",
""),
("Salade César",
"Salade romaine (1), Poulet grillé (300g), Croûtons (100g), Parmesan (50g), Sauce césar (15cl)",
"1. Laver et couper la salade\n2. Couper le poulet en lamelles\n3. Ajouter les croûtons et le parmesan\n4. Arroser de sauce césar\n5. Mélanger délicatement",
""),
("Tiramisu",
"Mascarpone (500g), Œufs (6), Sucre (150g), Café fort (30cl), Biscuits cuillère (400g), Cacao en poudre",
"1. Préparer le café et le laisser refroidir\n2. Séparer blancs et jaunes d'œufs\n3. Mélanger jaunes avec sucre et mascarpone\n4. Monter les blancs en neige et incorporer\n5. Tremper biscuits dans le café\n6. Alterner couches de biscuits et crème\n7. Saupoudrer de cacao\n8. Réfrigérer 4h minimum",
""),
("Poulet rôti",
"Poulet entier (1,5kg), Beurre (50g), Citron (1), Thym, Ail (3 gousses), Pommes de terre (1kg)",
"1. Préchauffer four à 200°C\n2. Farcir le poulet avec citron, thym et ail\n3. Badigeonner de beurre\n4. Disposer pommes de terre autour\n5. Cuire 1h15, arroser régulièrement",
""),
("Quiche Lorraine",
"Pâte brisée (1), Lardons (200g), Œufs (4), Crème fraîche (30cl), Gruyère râpé (100g), Noix de muscade",
"1. Préchauffer four à 180°C\n2. Étaler la pâte dans un moule\n3. Faire revenir les lardons\n4. Mélanger œufs, crème et gruyère\n5. Ajouter lardons et muscade\n6. Verser sur la pâte\n7. Cuire 35-40 minutes",
"")
]
try:
conn.executemany('''
INSERT INTO recettes (nom, ingredients, instructions, lien)
VALUES (?, ?, ?, ?)
''', recettes_exemple)
conn.commit()
print("✅ Base de données initialisée avec succès!")
print(f"{len(recettes_exemple)} recettes d'exemple ajoutées!")
except sqlite3.IntegrityError as e:
print(f"⚠️ Certaines recettes existent déjà : {e}")
else:
print(f"✅ Base de données déjà initialisée ({count} recettes)")
# ✅ Ajouter des accompagnements d'exemple
count_acc = conn.execute('SELECT COUNT(*) FROM accompagnements').fetchone()[0]
if count_acc == 0:
accompagnements_exemple = [
("Riz blanc", "Riz basmati ou thaï"),
("Pâtes", "Pâtes italiennes variées"),
("Frites", "Pommes de terre frites maison"),
("Salade verte", "Salade fraîche de saison"),
("Légumes vapeur", "Carottes, brocolis, haricots verts"),
("Purée", "Purée de pommes de terre maison"),
("Ratatouille", "Légumes du soleil mijotés")
]
try:
conn.executemany('''
INSERT INTO accompagnements (nom, descriptif)
VALUES (?, ?)
''', accompagnements_exemple)
conn.commit()
print(f"{len(accompagnements_exemple)} accompagnements d'exemple ajoutés!")
except sqlite3.IntegrityError as e:
print(f"⚠️ Certains accompagnements existent déjà : {e}")
conn.close()
# ✅ INITIALISATION AU DÉMARRAGE
init_db()
# ========== ROUTES PROTÉGÉES ==========
@app.route('/menu')
@app.route('/')
@login_required
def menu():
offset = int(request.args.get('offset', 0))
conn = get_connection()
# ✅ MODIFIÉ : Calculer les dates de la semaine AVANT la requête
today = datetime.now().date() + timedelta(weeks=offset)
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
# ✅ MODIFIÉ : Récupération du menu pour LA SEMAINE ACTUELLE UNIQUEMENT
menu_data = conn.execute('''
SELECT m.date, m.jour, m.repas, m.recette_id, r.nom, r.ingredients, r.lien
FROM menu m
LEFT JOIN recettes r ON m.recette_id = r.id
WHERE m.date BETWEEN ? AND ?
''', (start_of_week.isoformat(), end_of_week.isoformat())).fetchall()
menu = {}
for row in menu_data:
key = f"{row['jour']}_{row['repas']}"
menu[key] = {
'id': row['recette_id'],
'nom': row['nom'],
'ingredients': row['ingredients'],
'lien': row['lien']
}
# ✅ MODIFIÉ : Charger les accompagnements en utilisant la DATE + jour + repas
accompagnements = conn.execute('''
SELECT a.id, a.nom, a.descriptif
FROM menu_accompagnements ma
JOIN accompagnements a ON ma.accompagnement_id = a.id
WHERE ma.date = ? AND ma.jour = ? AND ma.repas = ?
ORDER BY a.nom
''', (row['date'], row['jour'], row['repas'])).fetchall()
menu[key]['accompagnements'] = [dict(acc) for acc in accompagnements]
# Calcul des dates pour l'affichage
days = []
jours = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
for i, jour in enumerate(jours):
date = start_of_week + timedelta(days=i)
days.append({
'name': jour,
'date': date.strftime('%d/%m/%Y')
})
# Récupération de toutes les recettes pour le dropdown
recettes = conn.execute('SELECT id, nom FROM recettes ORDER BY nom').fetchall()
# Récupération de tous les accompagnements pour le dropdown
tous_accompagnements = conn.execute('SELECT id, nom FROM accompagnements ORDER BY nom').fetchall()
start_date = start_of_week.strftime('%d/%m/%Y')
end_date = end_of_week.strftime('%d/%m/%Y')
conn.close()
return render_template('menu.html',
menu=menu,
days=days,
recettes=recettes,
tous_accompagnements=tous_accompagnements,
offset=offset,
start_date=start_date,
end_date=end_date)
@app.route('/add_to_menu', methods=['POST'])
@login_required
def add_to_menu():
day = request.form['day']
meal = request.form['meal']
recette_id = request.form['recette_id']
offset = int(request.form.get('offset', 0))
anchor = request.form.get('anchor', '') # ✅ AJOUTÉ
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
day_index = jours_fr.index(day)
target_date = start_of_week + timedelta(days=day_index)
conn = get_connection()
# Supprimer l'ancienne entrée avant d'ajouter la nouvelle
conn.execute('''
DELETE FROM menu
WHERE date = ? AND jour = ? AND repas = ?
''', (target_date.isoformat(), day, meal))
# Ajouter la nouvelle recette
conn.execute('''
INSERT INTO menu (date, jour, repas, recette_id)
VALUES (?, ?, ?, ?)
''', (target_date.isoformat(), day, meal, recette_id))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ
@app.route('/remove_from_menu', methods=['POST'])
@login_required
def remove_from_menu():
day = request.form['day']
meal = request.form['meal']
offset = int(request.form.get('offset', 0))
anchor = request.form.get('anchor', '') # ✅ AJOUTÉ
# Calculer la date exacte
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
day_index = jours_fr.index(day)
target_date = start_of_week + timedelta(days=day_index)
conn = get_connection()
# Supprimer la recette du menu
conn.execute('''
DELETE FROM menu
WHERE date = ? AND jour = ? AND repas = ?
''', (target_date.isoformat(), day, meal))
# ✅ MODIFIÉ : Supprimer aussi les accompagnements avec la DATE
conn.execute('''
DELETE FROM menu_accompagnements
WHERE date = ? AND jour = ? AND repas = ?
''', (target_date.isoformat(), day, meal))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ
# ✅ MODIFIÉ : Ajouter un accompagnement au menu avec la DATE
@app.route('/add_accompagnement_to_menu', methods=['POST'])
@login_required
def add_accompagnement_to_menu():
day = request.form['day']
meal = request.form['meal']
accompagnement_id = request.form['accompagnement_id']
offset = int(request.form.get('offset', 0))
anchor = request.form.get('anchor', '') # ✅ AJOUTÉ
# Calculer la date exacte
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
day_index = jours_fr.index(day)
target_date = start_of_week + timedelta(days=day_index)
conn = get_connection()
# Ajouter l'accompagnement pour CE jour/repas/date spécifique
conn.execute('''
INSERT OR IGNORE INTO menu_accompagnements (date, jour, repas, accompagnement_id)
VALUES (?, ?, ?, ?)
''', (target_date.isoformat(), day, meal, accompagnement_id))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ
# ✅ MODIFIÉ : Retirer un accompagnement du menu avec la DATE
@app.route('/remove_accompagnement_from_menu', methods=['POST'])
@login_required
def remove_accompagnement_from_menu():
day = request.form['day']
meal = request.form['meal']
accompagnement_id = request.form['accompagnement_id']
offset = int(request.form.get('offset', 0))
anchor = request.form.get('anchor', '') # ✅ AJOUTÉ
# Calculer la date exacte
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset)
jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
day_index = jours_fr.index(day)
target_date = start_of_week + timedelta(days=day_index)
conn = get_connection()
# Retirer l'accompagnement pour CE jour/repas/date spécifique
conn.execute('''
DELETE FROM menu_accompagnements
WHERE date = ? AND jour = ? AND repas = ? AND accompagnement_id = ?
''', (target_date.isoformat(), day, meal, accompagnement_id))
conn.commit()
conn.close()
return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ
@app.route('/change_week', methods=['POST'])
@login_required
def change_week():
offset = int(request.form['offset'])
return redirect(url_for('menu', offset=offset))
# ========== ✅ NOUVEAU : ROUTE STATISTIQUES ==========
@app.route('/stats')
@login_required
def stats():
conn = get_connection()
# 📊 Top 10 recettes les plus utilisées
top_recettes = conn.execute('''
SELECT r.nom, COUNT(*) as count
FROM menu m
JOIN recettes r ON m.recette_id = r.id
GROUP BY r.nom
ORDER BY count DESC
LIMIT 10
''').fetchall()
# 📊 Nombre total de recettes planifiées
total_planifiees = conn.execute('SELECT COUNT(*) as count FROM menu').fetchone()['count']
# 📊 Nombre total de recettes disponibles
total_recettes = conn.execute('SELECT COUNT(*) as count FROM recettes').fetchone()['count']
# 📊 Recettes jamais utilisées
jamais_utilisees = conn.execute('''
SELECT r.nom
FROM recettes r
LEFT JOIN menu m ON r.id = m.recette_id
WHERE m.id IS NULL
ORDER BY r.nom
''').fetchall()
# 📊 Accompagnements les plus utilisés
top_accompagnements = conn.execute('''
SELECT a.nom, COUNT(*) as count
FROM menu_accompagnements ma
JOIN accompagnements a ON ma.accompagnement_id = a.id
GROUP BY a.nom
ORDER BY count DESC
LIMIT 10
''').fetchall()
conn.close()
return render_template('stats.html',
top_recettes=top_recettes,
total_planifiees=total_planifiees,
total_recettes=total_recettes,
jamais_utilisees=jamais_utilisees,
top_accompagnements=top_accompagnements)
# ========== FIN NOUVEAU ==========
@app.route('/recettes')
@login_required
def recettes():
conn = get_connection()
search = request.args.get('search', '').strip()
if search:
recettes = conn.execute('''
SELECT * FROM recettes
WHERE nom LIKE ? OR ingredients LIKE ?
ORDER BY nom
''', (f'%{search}%', f'%{search}%')).fetchall()
else:
recettes = conn.execute('SELECT * FROM recettes ORDER BY nom').fetchall()
conn.close()
return render_template('recettes.html', recettes=recettes, search=search)
@app.route('/recettes/add', methods=['GET', 'POST'])
@login_required
def add_recette():
if request.method == 'POST':
nom = request.form['nom']
ingredients = request.form.get('ingredients', '')
instructions = request.form.get('instructions', '')
lien = request.form.get('lien', '')
conn = get_connection()
conn.execute('''
INSERT INTO recettes (nom, ingredients, instructions, lien)
VALUES (?, ?, ?, ?)
''', (nom, ingredients, instructions, lien))
conn.commit()
conn.close()
return redirect(url_for('recettes'))
return render_template('add_recette.html')
@app.route('/recettes/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit_recette(id):
conn = get_connection()
if request.method == 'POST':
nom = request.form['nom']
ingredients = request.form.get('ingredients', '')
instructions = request.form.get('instructions', '')
lien = request.form.get('lien', '')
# Mise à jour de la recette
conn.execute('''
UPDATE recettes
SET nom = ?, ingredients = ?, instructions = ?, lien = ?
WHERE id = ?
''', (nom, ingredients, instructions, lien, id))
# Gestion des accompagnements (suggestions par défaut pour cette recette)
# 1. Supprimer les anciennes associations
conn.execute('DELETE FROM recette_accompagnements WHERE recette_id = ?', (id,))
# 2. Ajouter les nouvelles associations
accompagnements_ids = request.form.getlist('accompagnements')
for acc_id in accompagnements_ids:
conn.execute('''
INSERT INTO recette_accompagnements (recette_id, accompagnement_id)
VALUES (?, ?)
''', (id, acc_id))
conn.commit()
conn.close()
flash('✅ Recette modifiée avec succès !', 'success')
return redirect(url_for('recettes'))
# Récupération des données pour l'affichage
recette = conn.execute('SELECT * FROM recettes WHERE id = ?', (id,)).fetchone()
accompagnements = conn.execute('SELECT * FROM accompagnements ORDER BY nom').fetchall()
# Récupérer les accompagnements sélectionnés
selected = conn.execute('''
SELECT accompagnement_id FROM recette_accompagnements
WHERE recette_id = ?
''', (id,)).fetchall()
selected_accompagnements = [row['accompagnement_id'] for row in selected]
conn.close()
return render_template('edit_recette.html',
recette=recette,
accompagnements=accompagnements,
selected_accompagnements=selected_accompagnements)
@app.route('/recettes/delete/<int:id>', methods=['POST'])
@login_required
def delete_recette(id):
conn = get_connection()
conn.execute('DELETE FROM recettes WHERE id = ?', (id,))
conn.commit()
conn.close()
return redirect(url_for('recettes'))
@app.route('/export_recettes')
@login_required
def export_recettes():
"""Exporter toutes les recettes en JSON"""
conn = get_connection()
recettes = conn.execute('SELECT nom, ingredients, instructions, lien FROM recettes ORDER BY nom').fetchall()
conn.close()
recettes_list = [dict(row) for row in recettes]
json_data = json.dumps(recettes_list, ensure_ascii=False, indent=2)
buffer = BytesIO()
buffer.write(json_data.encode('utf-8'))
buffer.seek(0)
filename = f"recettes_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
return send_file(
buffer,
mimetype='application/json',
as_attachment=True,
download_name=filename
)
@app.route('/import_recettes', methods=['POST'])
@login_required
def import_recettes():
"""Importer des recettes depuis un fichier JSON"""
if 'file' not in request.files:
flash('Aucun fichier sélectionné', 'error')
return redirect(url_for('recettes'))
file = request.files['file']
if file.filename == '':
flash('Aucun fichier sélectionné', 'error')
return redirect(url_for('recettes'))
if not file.filename.endswith('.json'):
flash('Le fichier doit être au format JSON', 'error')
return redirect(url_for('recettes'))
try:
data = json.load(file)
if not isinstance(data, list):
flash('Format JSON invalide (doit être une liste)', 'error')
return redirect(url_for('recettes'))
conn = get_connection()
imported = 0
updated = 0
for recette in data:
nom = recette.get('nom', '').strip()
if not nom:
continue
ingredients = recette.get('ingredients', '')
instructions = recette.get('instructions', '')
lien = recette.get('lien', '')
existing = conn.execute('SELECT id FROM recettes WHERE nom = ?', (nom,)).fetchone()
if existing:
conn.execute('''
UPDATE recettes
SET ingredients=?, instructions=?, lien=?
WHERE nom=?
''', (ingredients, instructions, lien, nom))
updated += 1
else:
conn.execute('''
INSERT INTO recettes (nom, ingredients, instructions, lien)
VALUES (?, ?, ?, ?)
''', (nom, ingredients, instructions, lien))
imported += 1
conn.commit()
conn.close()
flash(f'✅ Import réussi ! {imported} recette(s) ajoutée(s), {updated} mise(s) à jour', 'success')
except json.JSONDecodeError:
flash('❌ Erreur : fichier JSON invalide', 'error')
except Exception as e:
flash(f'❌ Erreur lors de l\'import : {str(e)}', 'error')
return redirect(url_for('recettes'))
# ========== ROUTES ACCOMPAGNEMENTS ==========
@app.route('/accompagnements')
@login_required
def accompagnements():
conn = get_connection()
accompagnements = conn.execute('SELECT * FROM accompagnements ORDER BY nom').fetchall()
conn.close()
return render_template('accompagnements.html', accompagnements=accompagnements)
@app.route('/accompagnements/add', methods=['GET', 'POST'])
@login_required
def add_accompagnement():
if request.method == 'POST':
nom = request.form['nom'].strip()
descriptif = request.form.get('descriptif', '').strip()
conn = get_connection()
conn.execute('''
INSERT INTO accompagnements (nom, descriptif)
VALUES (?, ?)
''', (nom, descriptif))
conn.commit()
conn.close()
flash('✅ Accompagnement ajouté avec succès !', 'success')
return redirect(url_for('accompagnements'))
return render_template('add_accompagnement.html')
@app.route('/accompagnements/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit_accompagnement(id):
conn = get_connection()
if request.method == 'POST':
nom = request.form['nom'].strip()
descriptif = request.form.get('descriptif', '').strip()
conn.execute('''
UPDATE accompagnements
SET nom = ?, descriptif = ?
WHERE id = ?
''', (nom, descriptif, id))
conn.commit()
conn.close()
flash('✅ Accompagnement modifié avec succès !', 'success')
return redirect(url_for('accompagnements'))
accompagnement = conn.execute('SELECT * FROM accompagnements WHERE id = ?', (id,)).fetchone()
conn.close()
return render_template('edit_accompagnement.html', accompagnement=accompagnement)
@app.route('/accompagnements/delete/<int:id>', methods=['POST'])
@login_required
def delete_accompagnement(id):
conn = get_connection()
conn.execute('DELETE FROM accompagnements WHERE id = ?', (id,))
conn.commit()
conn.close()
flash('✅ Accompagnement supprimé avec succès !', 'success')
return redirect(url_for('accompagnements'))
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0')

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
web:
build: .
container_name: recettes-app22
ports:
- 5010:5000
volumes:
- /DATA/appdata/menu-miam2/data:/app/data
environment:
- SECRET_KEY=26a9a087fd2c8e9a2d3a8f735bade5932a7c94de88b5cb2f345dac86bc5cb90a
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=H@Ct2o8mLG
restart: unless-stopped
networks: {}

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
Flask==3.0.0

991
static/style(2).css Normal file
View File

@@ -0,0 +1,991 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
/* Navigation supérieure */
.top-nav {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
gap: 10px;
align-items: center;
}
h1 {
color: #333;
margin-bottom: 30px;
text-align: center;
}
/* 🔔 MESSAGES FLASH */
.alert {
padding: 15px 20px;
margin: 20px 0;
border-radius: 8px;
border-left: 4px solid;
font-size: 15px;
}
.alert-success {
background: #d4edda;
border-color: #28a745;
color: #155724;
}
.alert-error {
background: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
.alert-info {
background: #d1ecf1;
border-color: #17a2b8;
color: #0c5460;
}
.alert-info ul {
margin: 10px 0 0 20px;
}
/* 🔍 BARRE DE RECHERCHE */
.search-form {
display: flex;
gap: 10px;
margin: 25px 0;
align-items: center;
}
.search-form input[type="text"] {
flex: 1;
padding: 12px 16px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
}
.search-form input[type="text"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-form button {
padding: 12px 24px;
white-space: nowrap;
}
.btn-clear {
padding: 12px 20px;
background: #dc3545;
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 16px;
white-space: nowrap;
transition: background 0.3s;
border: none;
cursor: pointer;
}
.btn-clear:hover {
background: #c82333;
}
.search-info {
color: #555;
font-style: italic;
margin: 15px 0;
padding: 12px 16px;
background: #f8f9fa;
border-left: 4px solid #667eea;
border-radius: 4px;
}
.search-info strong {
color: #667eea;
font-weight: 600;
}
/* Navigation semaine */
.week-nav {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 30px;
}
.week-nav button {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
}
.week-nav button:hover {
background: #5568d3;
}
/* ✅ GRILLE MENU - LARGEUR MAXIMALE */
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* ✅ Augmenté de 250px à 280px */
gap: 20px;
margin-bottom: 30px;
}
/* ✅ CARTE JOURNALIÈRE - PLUS D'ESPACE */
.day-card {
background: #f8f9fa;
border-radius: 10px;
padding: 20px; /* ✅ Augmenté de 15px à 20px */
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.day-card h3 {
color: #667eea;
margin-bottom: 5px;
}
.date {
font-size: 14px;
color: #666;
margin-bottom: 15px;
}
/* ✅ SECTION REPAS (MIDI/SOIR) - PLEINE LARGEUR */
.meal-slot {
margin-bottom: 20px; /* ✅ Augmenté de 15px à 20px */
width: 100%; /* ✅ IMPORTANT : prend toute la largeur */
}
.meal-slot h4 {
font-size: 15px; /* ✅ Légèrement plus grand */
margin-bottom: 10px;
color: #555;
font-weight: 600;
}
/* ✅ CARTE RECETTE - PLEINE LARGEUR */
.recipe-card {
background: white;
padding: 16px; /* ✅ Espace équilibré */
border-radius: 8px;
border-left: 4px solid #667eea;
width: 100%; /* ✅ IMPORTANT : prend toute la largeur */
box-sizing: border-box; /* ✅ Inclut padding et border dans la largeur */
margin-bottom: 12px;
}
.recipe-info {
width: 100%; /* ✅ IMPORTANT */
}
/* 🔗 NOM DE LA RECETTE CLIQUABLE */
.recipe-name {
margin-bottom: 10px;
}
.recipe-name a {
display: block;
font-weight: bold;
font-size: 1.05rem; /* ✅ Légèrement plus grand */
color: #2c3e50;
text-decoration: none;
transition: color 0.2s;
}
.recipe-name a:hover {
color: #3498db;
text-decoration: underline;
}
.recipe-name .print-only {
display: none;
font-weight: bold;
color: #2c3e50;
}
.recipe-title-link {
color: #2c3e50;
text-decoration: none;
transition: color 0.2s;
}
.recipe-title-link:hover {
color: #3498db;
text-decoration: underline;
}
/* ✅ INGRÉDIENTS - PLUS LISIBLES */
.ingredients-details {
margin-top: 10px;
width: 100%; /* ✅ IMPORTANT */
}
.ingredients-details summary {
cursor: pointer;
color: #667eea;
font-size: 14px;
margin-bottom: 8px;
font-weight: 500;
}
.ingredients-details pre {
background: #f8f9fa;
padding: 12px; /* ✅ Plus d'espace */
border-radius: 6px;
font-size: 13px; /* ✅ Légèrement plus grand */
margin-top: 8px;
white-space: pre-wrap;
line-height: 1.5; /* ✅ Meilleure lisibilité */
width: 100%; /* ✅ IMPORTANT */
box-sizing: border-box;
}
.recipe-link {
margin-top: 10px;
}
.recipe-link a {
color: #667eea;
text-decoration: none;
font-size: 13px;
}
.recipe-link a:hover {
text-decoration: underline;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
margin-top: 8px;
}
.delete-btn:hover {
background: #c82333;
}
select {
width: 100%;
padding: 10px; /* ✅ Plus d'espace */
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
/* 🔍 AUTOCOMPLETE RECETTES */
.recipe-search-form {
position: relative;
}
.recipe-autocomplete {
width: 100%;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
.recipe-autocomplete:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 2px solid #667eea;
border-top: none;
border-radius: 0 0 8px 8px;
max-height: 200px;
overflow-y: auto;
z-index: 100;
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.autocomplete-results.active {
display: block;
}
.autocomplete-item {
padding: 10px 12px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: #f0f4ff;
color: #667eea;
}
.autocomplete-no-results {
padding: 10px 12px;
color: #999;
font-style: italic;
text-align: center;
}
/* Liste recettes */
.recettes-list {
display: grid;
gap: 20px;
margin-bottom: 30px;
}
.recette-card {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #667eea;
}
.recette-card h3 {
color: #333;
margin-bottom: 15px;
}
.ingredients, .instructions {
margin-bottom: 15px;
}
.ingredients strong, .instructions strong {
color: #667eea;
display: block;
margin-bottom: 5px;
}
.ingredients pre, .instructions pre {
background: white;
padding: 10px;
border-radius: 5px;
white-space: pre-wrap;
font-family: inherit;
}
.actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
/* Formulaires */
.recipe-form {
max-width: 600px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-group label,
.form-label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.file-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
}
.file-input {
cursor: pointer;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 30px;
}
/* Boutons */
.btn {
display: inline-block;
background: #667eea;
color: white;
padding: 12px 24px;
border-radius: 8px;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 16px;
text-align: center;
transition: background 0.3s;
}
.btn:hover {
background: #5568d3;
}
.btn-secondary {
background: #6c757d;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-primary {
background: #667eea;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-success {
background: #28a745;
}
.btn-success:hover {
background: #218838;
}
.btn-info {
background: #17a2b8;
}
.btn-info:hover {
background: #138496;
}
.btn-print {
background: #28a745;
}
.btn-print:hover {
background: #218838;
}
.btn-edit {
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 5px;
text-decoration: none;
font-size: 14px;
}
.btn-edit:hover {
background: #218838;
}
.btn-delete {
background: #dc3545;
color: white;
padding: 8px 16px;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 14px;
}
.btn-delete:hover {
background: #c82333;
}
.btn-link {
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.btn-link:hover {
text-decoration: underline;
}
/* 📤 MODAL IMPORT */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #ddd;
}
.modal-header h2 {
margin: 0;
color: #333;
font-size: 20px;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #666;
line-height: 1;
padding: 0;
width: 30px;
height: 30px;
}
.close-btn:hover {
color: #dc3545;
}
.modal-body {
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px;
border-top: 1px solid #ddd;
}
/* ========== ✅ ACCOMPAGNEMENTS - PLEINE LARGEUR ========== */
.recipe-accompagnements {
margin: 15px 0;
padding: 14px;
background-color: #f0f8ff;
border-left: 4px solid #4CAF50;
border-radius: 6px;
width: 100%; /* ✅ IMPORTANT */
box-sizing: border-box;
}
.recipe-accompagnements strong {
color: #2c5f2d;
font-size: 0.95em;
display: block;
margin-bottom: 8px;
}
.recipe-accompagnements ul {
margin: 10px 0;
padding-left: 0;
list-style-type: none;
width: 100%; /* ✅ IMPORTANT */
}
.recipe-accompagnements li {
font-size: 0.9em;
color: #555;
margin: 8px 0;
padding: 8px 12px; /* ✅ Plus d'espace */
background: white;
border-radius: 4px;
display: flex;
align-items: center;
gap: 10px;
width: 100%; /* ✅ IMPORTANT */
box-sizing: border-box;
}
.recipe-accompagnements li strong {
color: #333;
font-weight: 600;
display: inline;
margin: 0;
}
.acc-descriptif {
color: #666;
font-size: 0.85em;
display: block;
margin-left: 0;
padding-left: 0;
}
.inline-form {
display: inline;
margin: 0;
margin-left: auto;
}
.delete-acc-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.9em;
padding: 2px 4px;
color: #d32f2f;
transition: transform 0.2s;
}
.delete-acc-btn:hover {
transform: scale(1.2);
}
/* ✅ FORMULAIRE AJOUT ACCOMPAGNEMENT - PLEINE LARGEUR */
.add-acc-form {
margin-top: 10px;
width: 100%; /* ✅ IMPORTANT */
}
.add-acc-form select {
width: 100%; /* ✅ IMPORTANT */
padding: 10px; /* ✅ Plus d'espace */
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9em;
background-color: white;
cursor: pointer;
box-sizing: border-box;
}
.add-acc-form select:hover {
border-color: #4CAF50;
}
.add-acc-form select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
/* Responsive pour mobile */
@media (max-width: 768px) {
.search-form {
flex-direction: column;
}
.search-form input[type="text"],
.search-form button,
.btn-clear {
width: 100%;
}
.top-nav {
flex-direction: column;
}
.top-nav > div {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.menu-grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
margin: 10px;
}
/* 📱 AUTOCOMPLETE MOBILE */
.recipe-autocomplete {
font-size: 16px;
padding: 12px 14px;
-webkit-appearance: none;
}
.autocomplete-results {
max-height: 250px;
border-radius: 0 0 12px 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
.autocomplete-item {
padding: 14px 16px;
font-size: 15px;
min-height: 48px;
display: flex;
align-items: center;
}
.autocomplete-no-results {
padding: 16px;
font-size: 15px;
}
.autocomplete-results {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
}
/* ========================================
IMPRESSION - A4 PAYSAGE OPTIMISÉ
======================================== */
.print-only {
display: none;
}
@page {
size: A4 landscape;
margin: 8mm;
}
@media print {
.no-print,
.week-nav,
.top-nav,
form,
button,
select,
.delete-btn,
.search-form,
.search-info,
.alert,
.autocomplete-results,
details summary::-webkit-details-marker {
display: none !important;
}
.print-only {
display: block !important;
}
.recipe-name .print-only {
display: block !important;
}
.recipe-name a {
display: none !important;
}
details,
details[open],
.ingredients-details {
display: block !important;
}
details summary ~ * {
display: block !important;
}
body {
margin: 0;
padding: 5mm;
font-size: 8pt;
line-height: 1.2;
background: white !important;
}
.container {
max-width: 100%;
padding: 0;
box-shadow: none;
border-radius: 0;
}
h1 {
font-size: 16pt;
margin: 0 0 5mm 0;
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 3mm;
color: #333 !important;
}
.menu-grid {
display: grid !important;
grid-template-columns: repeat(7, 1fr) !important;
gap: 3mm !important;
page-break-inside: avoid;
margin-bottom: 0;
}
.day-card {
border: 1px solid #333 !important;
padding: 2mm !important;
background: white !important;
page-break-inside: avoid;
border-radius: 0 !important;
box-shadow: none !important;
}
.day-card h3 {
font-size: 10pt !important;
margin: -2mm -2mm 2mm -2mm !important;
text-align: center;
background: #3498db !important;
color: white !important;
padding: 2mm !important;
border-radius: 0 !important;
}
.day-card .date {
font-size: 7pt !important;
text-align: center;
color: #666 !important;
margin: 0 0 2mm 0 !important;
}
.meal-slot {
margin-bottom: 3mm !important;
border-bottom: 1px solid #ddd;
padding-bottom: 2mm;
}
.meal-slot:last-child {
border-bottom: none;
margin-bottom: 0 !important;
}
.meal-slot h4 {
font-size: 8pt !important;
margin: 0 0 1mm 0 !important;
color: #2c3e50 !important;
font-weight: bold;
}
.recipe-card {
background: #f9f9f9 !important;
padding: 2mm !important;
border-left: 2px solid #3498db !important;
border-radius: 0 !important;
display: block !important;
}
.recipe-info {
width: 100%;
}
.ingredients-details {
margin-top: 1mm !important;
display: block !important;
}
.ingredients-details summary {
display: none !important;
}
.ingredients-details pre {
display: block !important;
font-size: 6.5pt !important;
line-height: 1.3 !important;
margin: 0 !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
background: white !important;
padding: 1mm !important;
border-left: 1px solid #ddd !important;
border-radius: 0 !important;
}
.recipe-link {
display: none !important;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
.day-card,
.recipe-card,
.meal-slot {
page-break-inside: avoid;
break-inside: avoid;
}
.recipe-accompagnements {
background: #f9f9f9 !important;
border-left: 2px solid #999 !important;
page-break-inside: avoid;
}
.recipe-accompagnements strong {
color: #333 !important;
}
.delete-acc-btn,
.add-acc-form {
display: none !important;
}
.acc-descriptif {
display: inline;
margin-left: 4px;
}
}

1002
static/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes accompagnements</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<div class="top-nav">
<a href="{{ url_for('menu') }}" class="btn btn-secondary">← Retour au menu</a>
<a href="{{ url_for('add_accompagnement') }}" class="btn"> Ajouter un accompagnement</a>
</div>
<h1>🥗 Mes accompagnements</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if accompagnements %}
<div class="recettes-list">
{% for acc in accompagnements %}
<div class="recette-card">
<h3>{{ acc['nom'] }}</h3>
{% if acc['descriptif'] %}
<div class="ingredients">
<strong>Descriptif :</strong>
<pre>{{ acc['descriptif'] }}</pre>
</div>
{% endif %}
<div class="actions">
<a href="{{ url_for('edit_accompagnement', id=acc['id']) }}" class="btn-edit">✏️ Modifier</a>
<form method="POST" action="{{ url_for('delete_accompagnement', id=acc['id']) }}" style="display: inline;">
<button type="submit" class="btn-delete" onclick="return confirm('Supprimer cet accompagnement ?')">🗑️ Supprimer</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; color: #666; margin: 40px 0;">
Aucun accompagnement pour le moment. Commencez par en ajouter un !
</p>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ajouter un accompagnement</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h1> Ajouter un accompagnement</h1>
<form method="POST" action="{{ url_for('add_accompagnement') }}" class="recipe-form">
<div class="form-group">
<label for="nom">Nom de l'accompagnement *</label>
<input type="text" id="nom" name="nom" required placeholder="Ex: Riz basmati, Frites maison...">
</div>
<div class="form-group">
<label for="descriptif">Descriptif (optionnel)</label>
<textarea id="descriptif" name="descriptif" rows="4" placeholder="Préparation, suggestions, notes..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn">💾 Enregistrer</button>
<a href="{{ url_for('accompagnements') }}" class="btn btn-secondary">❌ Annuler</a>
</div>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ajouter une recette</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h1> Ajouter une recette</h1>
<form method="POST" action="{{ url_for('add_recette') }}" class="recipe-form">
<div class="form-group">
<label for="nom">Nom de la recette *</label>
<input type="text" id="nom" name="nom" required>
</div>
<div class="form-group">
<label for="lien">Lien internet (optionnel)</label>
<input type="url" id="lien" name="lien" placeholder="https://exemple.com/ma-recette">
</div>
<div class="form-group">
<label for="ingredients">Ingrédients</label>
<textarea id="ingredients" name="ingredients" rows="6" placeholder="- Ingrédient 1&#10;- Ingrédient 2&#10;- ..."></textarea>
</div>
<div class="form-group">
<label for="instructions">Instructions</label>
<textarea id="instructions" name="instructions" rows="8" placeholder="1. Étape 1&#10;2. Étape 2&#10;3. ..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn">💾 Enregistrer</button>
<a href="{{ url_for('recettes') }}" class="btn btn-secondary">❌ Annuler</a>
</div>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modifier un accompagnement</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h1>✏️ Modifier un accompagnement</h1>
<form method="POST" action="{{ url_for('edit_accompagnement', id=accompagnement['id']) }}" class="recipe-form">
<div class="form-group">
<label for="nom">Nom de l'accompagnement *</label>
<input type="text" id="nom" name="nom" value="{{ accompagnement['nom'] }}" required>
</div>
<div class="form-group">
<label for="descriptif">Description (optionnel)</label>
<textarea id="descriptif" name="descriptif" rows="3">{{ accompagnement['descriptif'] or '' }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 Enregistrer</button>
<a href="{{ url_for('accompagnements') }}" class="btn btn-secondary">❌ Annuler</a>
</div>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modifier une recette</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h1>✏️ Modifier la recette</h1>
<form method="POST" action="{{ url_for('edit_recette', id=recette['id']) }}" class="recipe-form">
<div class="form-group">
<label for="nom">Nom de la recette *</label>
<input type="text" id="nom" name="nom" value="{{ recette['nom'] }}" required>
</div>
<div class="form-group">
<label for="lien">Lien internet (optionnel)</label>
<input type="url" id="lien" name="lien" value="{{ recette['lien'] or '' }}" placeholder="https://exemple.com/ma-recette">
</div>
<div class="form-group">
<label for="ingredients">Ingrédients</label>
<textarea id="ingredients" name="ingredients" rows="6">{{ recette['ingredients'] or '' }}</textarea>
</div>
<div class="form-group">
<label for="instructions">Instructions</label>
<textarea id="instructions" name="instructions" rows="8">{{ recette['instructions'] or '' }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn">💾 Enregistrer</button>
<a href="{{ url_for('recettes') }}" class="btn btn-secondary">❌ Annuler</a>
</div>
</form>
</div>
</body>
</html>

108
templates/login.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - Menu Miam</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
}
.flash-messages {
margin-bottom: 20px;
}
.flash {
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}
.flash.success { background: #d4edda; color: #155724; }
.flash.error { background: #f8d7da; color: #721c24; }
.flash.warning { background: #fff3cd; color: #856404; }
</style>
</head>
<body>
<div class="login-container">
<h1>🍽️ Menu Miam</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label for="username">Nom d'utilisateur</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Se connecter</button>
</form>
</div>
</body>
</html>

141
templates/menu(1).html Normal file
View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Menu de la semaine</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<div class="top-nav no-print">
<a href="{{ url_for('recettes') }}" class="btn">📚 Gérer mes recettes</a>
<a href="{{ url_for('accompagnements') }}" class="btn btn-info">🥗 Gérer les accompagnements</a>
<button onclick="window.print()" class="btn btn-print">🖨️ Imprimer</button>
</div>
<h1>🍽️ Menu de la semaine</h1>
<!-- Navigation semaine -->
<div class="week-nav no-print">
<form method="POST" action="{{ url_for('change_week') }}" style="display: inline;">
<input type="hidden" name="offset" value="{{ offset - 1 }}">
<button type="submit">◀ Semaine précédente</button>
</form>
<h2>Semaine du {{ start_date }} au {{ end_date }}</h2>
<form method="POST" action="{{ url_for('change_week') }}" style="display: inline;">
<input type="hidden" name="offset" value="{{ offset + 1 }}">
<button type="submit">Semaine suivante ▶</button>
</form>
</div>
<!-- Grille menu -->
<div class="menu-grid">
{% for day in days %}
<div class="day-card" id="{{ day.name }}">
<h3>{{ day.name }}</h3>
<p class="date">{{ day.date }}</p>
{% for meal_type in ['Midi', 'Soir'] %}
<div class="meal-slot">
<h4>{{ meal_type }}</h4>
{% set meal_key = day.name + '_' + meal_type %}
{% if menu.get(meal_key) %}
<div class="recipe-card">
<div class="recipe-info">
<!-- 🔗 NOM DE LA RECETTE CLIQUABLE VERS LA PAGE D'ÉDITION -->
<div class="recipe-name">
<a href="{{ url_for('edit_recette', id=menu[meal_key]['id']) }}" class="recipe-title-link no-print">{{ menu[meal_key]['nom'] }}</a>
<span class="print-only">{{ menu[meal_key]['nom'] }}</span>
</div>
{% if menu[meal_key].get('ingredients') %}
<details class="ingredients-details" open>
<summary>📝 Ingrédients</summary>
<pre>{{ menu[meal_key]['ingredients'] }}</pre>
</details>
{% endif %}
<!-- ✅ ACCOMPAGNEMENTS AVEC AJOUT/SUPPRESSION -->
<div class="recipe-accompagnements">
<strong>🥗 Accompagnements</strong>
{% if menu[meal_key].get('accompagnements') and menu[meal_key]['accompagnements']|length > 0 %}
<ul>
{% for acc in menu[meal_key]['accompagnements'] %}
<li>
<strong>{{ acc['nom'] }}</strong>
{% if acc['descriptif'] %}
<em class="acc-descriptif">{{ acc['descriptif'] }}</em>
{% endif %}
<!-- ✅ BOUTON POUR RETIRER L'ACCOMPAGNEMENT -->
<form method="POST" action="{{ url_for('remove_accompagnement_from_menu') }}" class="inline-form no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="accompagnement_id" value="{{ acc['id'] }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<button type="submit" class="delete-acc-btn" title="Retirer cet accompagnement"></button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<!-- ✅ DROPDOWN POUR AJOUTER UN ACCOMPAGNEMENT -->
<form method="POST" action="{{ url_for('add_accompagnement_to_menu') }}" class="add-acc-form no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<select name="accompagnement_id" onchange="this.form.submit()">
<option value="" disabled selected>Ajouter un accompagnement...</option>
{% for acc in tous_accompagnements %}
<option value="{{ acc['id'] }}">{{ acc['nom'] }}</option>
{% endfor %}
</select>
</form>
</div>
{% if menu[meal_key].get('lien') %}
<div class="recipe-link">
<a href="{{ menu[meal_key]['lien'] }}" target="_blank" class="no-print">🔗 Voir la recette</a>
<span class="print-only">🔗 {{ menu[meal_key]['lien'] }}</span>
</div>
{% endif %}
</div>
<form method="POST" action="{{ url_for('remove_from_menu') }}" class="no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<button type="submit" class="delete-btn">🗑️</button>
</form>
</div>
{% else %}
<form method="POST" action="{{ url_for('add_to_menu') }}" class="no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<select name="recette_id" onchange="this.form.submit()">
<option value="">-- Choisir --</option>
{% for recette in recettes|sort(attribute='nom') %}
<option value="{{ recette['id'] }}">{{ recette['nom'] }}</option>
{% endfor %}
</select>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</body>
</html>

142
templates/menu.html Normal file
View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Menu de la semaine</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<div class="top-nav no-print">
<a href="{{ url_for('recettes') }}" class="btn">📚 Gérer mes recettes</a>
<a href="{{ url_for('accompagnements') }}" class="btn btn-info">🥗 Gérer les accompagnements</a>
<a href="{{ url_for('stats') }}" class="btn btn-success">📊 Statistiques</a>
<button onclick="window.print()" class="btn btn-print">🖨️ Imprimer</button>
</div>
<h1>🍽️ Menu de la semaine</h1>
<!-- Navigation semaine -->
<div class="week-nav no-print">
<form method="POST" action="{{ url_for('change_week') }}" style="display: inline;">
<input type="hidden" name="offset" value="{{ offset - 1 }}">
<button type="submit">◀ Semaine précédente</button>
</form>
<h2>Semaine du {{ start_date }} au {{ end_date }}</h2>
<form method="POST" action="{{ url_for('change_week') }}" style="display: inline;">
<input type="hidden" name="offset" value="{{ offset + 1 }}">
<button type="submit">Semaine suivante ▶</button>
</form>
</div>
<!-- Grille menu -->
<div class="menu-grid">
{% for day in days %}
<div class="day-card" id="{{ day.name }}">
<h3>{{ day.name }}</h3>
<p class="date">{{ day.date }}</p>
{% for meal_type in ['Midi', 'Soir'] %}
<div class="meal-slot">
<h4>{{ meal_type }}</h4>
{% set meal_key = day.name + '_' + meal_type %}
{% if menu.get(meal_key) %}
<div class="recipe-card">
<div class="recipe-info">
<!-- 🔗 NOM DE LA RECETTE CLIQUABLE VERS LA PAGE D'ÉDITION -->
<div class="recipe-name">
<a href="{{ url_for('edit_recette', id=menu[meal_key]['id']) }}" class="recipe-title-link no-print">{{ menu[meal_key]['nom'] }}</a>
<span class="print-only">{{ menu[meal_key]['nom'] }}</span>
</div>
{% if menu[meal_key].get('ingredients') %}
<details class="ingredients-details" open>
<summary>📝 Ingrédients</summary>
<pre>{{ menu[meal_key]['ingredients'] }}</pre>
</details>
{% endif %}
<!-- ✅ ACCOMPAGNEMENTS AVEC AJOUT/SUPPRESSION -->
<div class="recipe-accompagnements">
<strong>🥗 Accompagnements</strong>
{% if menu[meal_key].get('accompagnements') and menu[meal_key]['accompagnements']|length > 0 %}
<ul>
{% for acc in menu[meal_key]['accompagnements'] %}
<li>
<strong>{{ acc['nom'] }}</strong>
{% if acc['descriptif'] %}
<em class="acc-descriptif">{{ acc['descriptif'] }}</em>
{% endif %}
<!-- ✅ BOUTON POUR RETIRER L'ACCOMPAGNEMENT -->
<form method="POST" action="{{ url_for('remove_accompagnement_from_menu') }}" class="inline-form no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="accompagnement_id" value="{{ acc['id'] }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<button type="submit" class="delete-acc-btn" title="Retirer cet accompagnement"></button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<!-- ✅ DROPDOWN POUR AJOUTER UN ACCOMPAGNEMENT -->
<form method="POST" action="{{ url_for('add_accompagnement_to_menu') }}" class="add-acc-form no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<select name="accompagnement_id" onchange="this.form.submit()">
<option value="" disabled selected>Ajouter un accompagnement...</option>
{% for acc in tous_accompagnements %}
<option value="{{ acc['id'] }}">{{ acc['nom'] }}</option>
{% endfor %}
</select>
</form>
</div>
{% if menu[meal_key].get('lien') %}
<div class="recipe-link">
<a href="{{ menu[meal_key]['lien'] }}" target="_blank" class="no-print">🔗 Voir la recette</a>
<span class="print-only">🔗 {{ menu[meal_key]['lien'] }}</span>
</div>
{% endif %}
</div>
<form method="POST" action="{{ url_for('remove_from_menu') }}" class="no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<button type="submit" class="delete-btn">🗑️</button>
</form>
</div>
{% else %}
<form method="POST" action="{{ url_for('add_to_menu') }}" class="no-print">
<input type="hidden" name="day" value="{{ day.name }}">
<input type="hidden" name="meal" value="{{ meal_type }}">
<input type="hidden" name="offset" value="{{ offset }}">
<input type="hidden" name="anchor" value="{{ day.name }}">
<select name="recette_id" onchange="this.form.submit()">
<option value="">-- Choisir --</option>
{% for recette in recettes|sort(attribute='nom') %}
<option value="{{ recette['id'] }}">{{ recette['nom'] }}</option>
{% endfor %}
</select>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</body>
</html>

157
templates/recettes.html Normal file
View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes recettes</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<div class="top-nav">
<div>
<a href="{{ url_for('menu') }}" class="btn btn-secondary">← Retour au menu</a>
</div>
<div style="display: flex; gap: 10px;">
<a href="{{ url_for('add_recette') }}" class="btn"> Ajouter une recette</a>
<a href="{{ url_for('export_recettes') }}" class="btn btn-success">📥 Exporter</a>
<button type="button" class="btn btn-info" onclick="openImportModal()">📤 Importer</button>
</div>
</div>
<h1>📚 Mes recettes</h1>
<!-- 🔔 MESSAGES FLASH -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- 🔍 BARRE DE RECHERCHE -->
<form method="GET" action="{{ url_for('recettes') }}" class="search-form">
<input
type="text"
name="search"
placeholder="🔍 Rechercher une recette ou un ingrédient..."
value="{{ search or '' }}"
autofocus
>
<button type="submit" class="btn">Rechercher</button>
{% if search %}
<a href="{{ url_for('recettes') }}" class="btn-clear">✖ Effacer</a>
{% endif %}
</form>
<!-- Affichage nombre de résultats -->
{% if search %}
<p class="search-info">
{% if recettes %}
{{ recettes|length }} résultat(s) pour "<strong>{{ search }}</strong>"
{% else %}
Aucun résultat pour "<strong>{{ search }}</strong>"
{% endif %}
</p>
{% endif %}
{% if recettes %}
<div class="recettes-list">
{% for recette in recettes %}
<div class="recette-card">
<h3>{{ recette['nom'] }}</h3>
{% if recette['lien'] %}
<div class="recipe-link" style="margin-bottom: 15px;">
<a href="{{ recette['lien'] }}" target="_blank" class="btn-link">🔗 Voir la recette en ligne</a>
</div>
{% endif %}
{% if recette['ingredients'] %}
<div class="ingredients">
<strong>Ingrédients :</strong>
<pre>{{ recette['ingredients'] }}</pre>
</div>
{% endif %}
{% if recette['instructions'] %}
<div class="instructions">
<strong>Instructions :</strong>
<pre>{{ recette['instructions'] }}</pre>
</div>
{% endif %}
<div class="actions">
<a href="{{ url_for('edit_recette', id=recette['id']) }}" class="btn-edit">✏️ Modifier</a>
<form method="POST" action="{{ url_for('delete_recette', id=recette['id']) }}" style="display: inline;">
<button type="submit" class="btn-delete" onclick="return confirm('Supprimer cette recette ?')">🗑️ Supprimer</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
{% if search %}
<p style="text-align: center; color: #666; margin: 40px 0;">
Aucune recette trouvée. <a href="{{ url_for('recettes') }}">Voir toutes les recettes</a>
</p>
{% else %}
<p style="text-align: center; color: #666; margin: 40px 0;">
Aucune recette pour le moment. Commencez par en ajouter une !
</p>
{% endif %}
{% endif %}
</div>
<!-- 📤 MODAL IMPORT -->
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>📤 Importer des recettes</h2>
<button class="close-btn" onclick="closeImportModal()">&times;</button>
</div>
<div class="modal-body">
<form method="POST" action="{{ url_for('import_recettes') }}" enctype="multipart/form-data" id="importForm">
<label class="form-label">Sélectionnez un fichier JSON :</label>
<input type="file" name="file" accept=".json" class="file-input" required>
<div class="alert-info">
<strong> Format attendu :</strong>
<ul>
<li>Fichier JSON exporté depuis cette application</li>
<li>Les recettes existantes avec le même nom seront mises à jour</li>
<li>Les nouvelles recettes seront ajoutées</li>
</ul>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeImportModal()">Annuler</button>
<button type="submit" form="importForm" class="btn btn-primary">Importer</button>
</div>
</div>
</div>
<script>
function openImportModal() {
document.getElementById('importModal').style.display = 'flex';
}
function closeImportModal() {
document.getElementById('importModal').style.display = 'none';
document.getElementById('importForm').reset();
}
// Fermer modal en cliquant en dehors
window.onclick = function(event) {
const modal = document.getElementById('importModal');
if (event.target == modal) {
closeImportModal();
}
}
</script>
</body>
</html>

150
templates/stats.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📊 Statistiques - Menu Miam</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style>
.stats-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card h2 {
margin-top: 0;
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
.stat-number {
font-size: 48px;
font-weight: bold;
color: #3498db;
text-align: center;
margin: 20px 0;
}
.stat-list {
list-style: none;
padding: 0;
}
.stat-list li {
padding: 10px;
margin: 5px 0;
background: #f8f9fa;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-badge {
background: #3498db;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 20px;
background: #3498db;
color: white;
text-decoration: none;
border-radius: 5px;
}
.back-button:hover {
background: #2980b9;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
</style>
</head>
<body>
<div class="stats-container">
<a href="{{ url_for('menu') }}" class="back-button">← Retour au menu</a>
<h1>📊 Statistiques des recettes</h1>
<!-- Statistiques générales -->
<div class="grid-2">
<div class="stat-card">
<h2>📚 Recettes disponibles</h2>
<div class="stat-number">{{ total_recettes }}</div>
</div>
<div class="stat-card">
<h2>📅 Recettes planifiées</h2>
<div class="stat-number">{{ total_planifiees }}</div>
</div>
</div>
<!-- Top 10 recettes -->
<div class="stat-card">
<h2>🏆 Top 10 des recettes les plus utilisées</h2>
{% if top_recettes %}
<ul class="stat-list">
{% for recette in top_recettes %}
<li>
<span>{{ recette['nom'] }}</span>
<span class="stat-badge">{{ recette['count'] }}×</span>
</li>
{% endfor %}
</ul>
{% else %}
<p style="text-align: center; color: #7f8c8d;">Aucune recette planifiée pour le moment</p>
{% endif %}
</div>
<!-- Top 10 accompagnements -->
<div class="stat-card">
<h2>🥗 Top 10 des accompagnements les plus utilisés</h2>
{% if top_accompagnements %}
<ul class="stat-list">
{% for acc in top_accompagnements %}
<li>
<span>{{ acc['nom'] }}</span>
<span class="stat-badge">{{ acc['count'] }}×</span>
</li>
{% endfor %}
</ul>
{% else %}
<p style="text-align: center; color: #7f8c8d;">Aucun accompagnement utilisé pour le moment</p>
{% endif %}
</div>
<!-- Recettes jamais utilisées -->
<div class="stat-card">
<h2>💤 Recettes jamais utilisées ({{ jamais_utilisees|length }})</h2>
{% if jamais_utilisees %}
<ul class="stat-list">
{% for recette in jamais_utilisees %}
<li>{{ recette['nom'] }}</li>
{% endfor %}
</ul>
{% else %}
<p style="text-align: center; color: #27ae60; font-weight: bold;">🎉 Toutes vos recettes ont été utilisées au moins une fois !</p>
{% endif %}
</div>
</div>
</body>
</html>