première release
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
venv/
|
||||
env/
|
||||
*.db
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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
19
README.md
Normal 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
|
||||
BIN
__pycache__/app.cpython-311.pyc
Normal file
BIN
__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-313.pyc
Normal file
BIN
__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
215
app-version-PC.py
Normal file
215
app-version-PC.py
Normal 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
741
app.py
Normal 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
14
docker-compose.yml
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Flask==3.0.0
|
||||
991
static/style(2).css
Normal file
991
static/style(2).css
Normal 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
1002
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
57
templates/accompagnements.html
Normal file
57
templates/accompagnements.html
Normal 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>
|
||||
31
templates/add_accompagnement.html
Normal file
31
templates/add_accompagnement.html
Normal 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>
|
||||
41
templates/add_recette.html
Normal file
41
templates/add_recette.html
Normal 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 - Ingrédient 2 - ..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="instructions">Instructions</label>
|
||||
<textarea id="instructions" name="instructions" rows="8" placeholder="1. Étape 1 2. Étape 2 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>
|
||||
31
templates/edit_accompagnement.html
Normal file
31
templates/edit_accompagnement.html
Normal 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>
|
||||
41
templates/edit_recette.html
Normal file
41
templates/edit_recette.html
Normal 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
108
templates/login.html
Normal 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
141
templates/menu(1).html
Normal 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
142
templates/menu.html
Normal 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
157
templates/recettes.html
Normal 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()">×</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
150
templates/stats.html
Normal 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>
|
||||
Reference in New Issue
Block a user