Private
Public Access
1
0

moved to server

Update readme.md

Signed-off-by: rasmus <rsbendtsen@gmail.com>

Update readme.md

Update Dockerfile

Update Dockerfile
This commit is contained in:
2025-12-29 06:19:31 +01:00
commit dd2c15ba67
15 changed files with 1022 additions and 0 deletions

6
.Dockerignore Normal file
View File

@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.env
instance/
.git/

View File

@@ -0,0 +1,29 @@
name: Build and Push Madplaner
run-name: ${{ gitea.actor }} building image
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.rasmusbendtsen.dk
username: ${{ gitea.actor }}
password: ${{ secrets.GITEATOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
git.rasmusbendtsen.dk/rasmus/madplaner:latest

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
venv/
.env
*.db
.github/

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
# Initialize DB, then start Gunicorn
CMD python -c "from app import app, db; app.app_context().push(); db.create_all()" && \
gunicorn --bind 0.0.0.0:5000 app:app

207
app.py Normal file
View File

@@ -0,0 +1,207 @@
import os
from dotenv import load_dotenv
from flask import Flask, render_template, redirect, url_for, request, flash
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager,
UserMixin,
login_user,
login_required,
logout_user,
current_user,
)
from werkzeug.security import generate_password_hash, check_password_hash
from openai import OpenAI
load_dotenv()
OPENROUTER_API_KEY=os.environ.get("OPENROUTER_API_KEY")
OPENROUTER_MODEL=os.environ.get("OPENROUTER_MODEL")
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///mealplan.db"
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = "login"
# Anvender Open-router
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=OPENROUTER_API_KEY,
default_headers={
"HTTP-Referer": "http://localhost:5000",
"X-Title": "ChefGPT Meal Planner",
},
)
# --- Models ---
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password = db.Column(db.String(150), nullable=False)
plans = db.relationship("MealPlan", backref="creator", lazy=True)
class MealPlan(db.Model):
id = db.Column(db.Integer, primary_key=True)
summary = db.Column(db.Text, nullable=False)
shopping = db.Column(db.Text, nullable=False)
recipes = db.Column(db.Text, nullable=False)
description = db.Column(db.Text, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@app.route("/")
def index():
return render_template("index.html",source_link=os.environ.get("SOURCECODE_LINK"))
@app.route("/about")
def about():
return render_template("about.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("index"))
@app.route("/dashboard")
@login_required
def dashboard():
# Show the user's previous plans
plans = (
MealPlan.query.filter_by(user_id=current_user.id)
.order_by(MealPlan.id.desc())
.limit(3)
.all()
)
return render_template("dashboard.html", plans=plans)
@app.route("/plan/<int:plan_id>")
@login_required
def view_plan(plan_id):
plan = MealPlan.query.get_or_404(plan_id)
if plan.user_id != current_user.id:
flash("You do not have permission to view this plan.")
return redirect(url_for("dashboard"))
return render_template("plan_result.html", plan=plan)
@app.route("/generate", methods=["POST"])
@login_required
def generate():
data = {
"people_count": request.form.get("people_count"),
"budget_level": request.form.get("budget_level"),
"max_cooking_time": request.form.get("max_cooking_time"),
"skill_level": request.form.get("skill_level"),
"dietary_focus": request.form.get("dietary_focus"),
"dietary_preference": request.form.get("dietary_preference"),
"meal_strategy": request.form.get("meal_strategy"),
"fridge_items": request.form.get("fridge_items") or "None (start from scratch)",
"output_language": request.form.get("output_language", "English"),
}
try:
with open("system_prompt.txt", "r", encoding="utf-8") as f:
prompt_template = f.read()
# indsætter formular parametre i systemprompt
final_prompt = prompt_template.format(**data)
except FileNotFoundError:
flash("System prompt file missing. Please contact admin.", "error")
return redirect(url_for("dashboard"))
# Anvender open-router free modeller
try:
response = client.chat.completions.create(
model=OPENROUTER_MODEL,
messages=[{"role": "system", "content": final_prompt}],
temperature=0.7,
)
ai_output = response.choices[0].message.content
except Exception as e:
flash(f"AI Service Error: {str(e)}", "error")
return redirect(url_for("dashboard"))
# begynder parsing af AI svaret. Svaret skal leve op til formatkravet for at det kan indlæses pænt
def extract_section(text, start_tag, end_tag):
start = text.find(start_tag)
end = text.find(end_tag)
if start == -1 or end == -1 or end <= start:
return None
return text[start + len(start_tag) : end].strip()
desc = extract_section(ai_output, "[[DESCRIPTION_START]]", "[[DESCRIPTION_END]]")
summ = extract_section(ai_output, "[[SUMMARY_START]]", "[[SUMMARY_END]]")
shop = extract_section(ai_output, "[[SHOPPING_START]]", "[[SHOPPING_END]]")
reci = extract_section(ai_output, "[[RECIPES_START]]", "[[RECIPES_END]]")
if not all([desc, summ, shop, reci]):
flash("The plan could not be generated. Please try generating again.", "error")
return redirect(url_for("dashboard"))
# hvis AI svaret levede op til format kravet, gemmes det i db
try:
new_plan = MealPlan(
description=desc,
summary=summ,
shopping=shop,
recipes=reci,
user_id=current_user.id,
)
db.session.add(new_plan)
db.session.commit()
return redirect(url_for("view_plan", plan_id=new_plan.id))
except Exception as e:
db.session.rollback()
flash(f"Database Error: {str(e)}", "error")
return redirect(url_for("dashboard"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = User.query.filter_by(username=request.form.get("username")).first()
if user and check_password_hash(user.password, request.form.get("password")):
login_user(user)
return redirect(url_for("dashboard"))
flash("Invalid credentials")
return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
new_user = User(
username=request.form.get("username"),
password=generate_password_hash(
request.form.get("password"), method="pbkdf2:sha256"
),
)
db.session.add(new_user)
db.session.commit()
return redirect(url_for("/"))
return render_template("register.html")
if __name__ == "__main__":
with app.app_context():
db.create_all()
app.run()

16
compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
web:
image: git.rasmusbendtsen.dk/rasmus/madplaner:latest
container_name: madplaner
restart: always
ports:
- "${HOST_PORT:-80}:5000"
volumes:
# sqlite DB lives here
- ./instance:/app/instance
environment:
- FLASK_ENV=production
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OPENROUTER_MODEL= ${OPENROUTER_MODEL}
- SECRET_KEY=${SECRET_KEY}
- SOURCECODE_LINK=${SOURCECODE_LINK}

58
readme.md Normal file
View File

@@ -0,0 +1,58 @@
# [madplaner.rasmusbendtsen.dk](https://madplaner.rasmusbendtsen.dk)
## Introkduktion
Min kæreste og jeg var trætte af altid at spise spagetti kødsovs eller boller i karry.
Vi forsøgte os med forskellige måltidskasserne, men sad hurtigt tilbage med en følelse af, at det aldrig rigtig var pengene værd. Især fordi vi primært anvendte som at måde at slippe for, altid at skulle tænke over "hvad skal spise de næste par og hvilke vare skal vi købe". Selve det at handle ind og madlavningen, er ikke et problem. Det handlede mest om planlægningen. Hvis det er eneste formål, så er måltidskasser en dyr løsning.
Jeg anvendte ChatGPT til hurtigt at generer en indkøbsliste til fire retter. Det fungeret overraskende godt. Min kæreste sagde så, "Skal jeg så printe det ud, inden jeg går ud og handler"
![Michael Jordan meme](https://media1.tenor.com/m/B3_AR5dup94AAAAd/michael-jordan.gif)
Derfor lavet jeg denne løsning i stedet.
Den fungere ved at brugeren udfylder nogle inputfelter, som flettes ind i systempromten til en LLM. Svaret fra sprogmodellen sættes så pænt og let tilgængeligt op.
## Start din egen instans
```bash
mkdir madplaner; \
cd madplaner; \
mkdir instance; \
wget https://git.rasmusbendtsen.dk/rasmus/madplaner/raw/branch/main/compose.yml; \
#Opret .env fil eller rediger direkte i compose fil og derefter
docker compose up -d
```
Docker compose
```yaml
services:
web:
image: git.rasmusbendtsen.dk/rasmus/madplaner:latest
container_name: madplaner
restart: always
ports:
- "${HOST_PORT:-80}:5000"
volumes:
# sqlite DB lives here
- ./instance:/app/instance
environment:
- FLASK_ENV=production
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OPENROUTER_MODEL= ${OPENROUTER_MODEL}
- SECRET_KEY=${SECRET_KEY}
# Anvendes hvis der skal vises link til klidekode på forsiden
- SOURCECODE_LINK=${SOURCECODE_LINK}
```
## Backend
Backenden er lavet med Flask og SQlite. Sprogmodellen kaldes gennem Open-Router. Der bør tilføjes server side validering af inputfelterne for at forhindre prompt injection.
Servicen kører fra en ældre kontor pc i min lejlighed i Valby. Den er gjort tilgængelig gennem en omvendt proxy på den billigeste VPS jeg kunne finde, ved brug af [Pangolin](https://github.com/fosrl/pangolin)
## Frontend
Frontenden er lavet med tailwindcss. Jeg har forsøgt at begrænse anvendelsen af javascript. Layout og UI er 100% vibecoded.
## Tilpasning
Ved at redigere i dashboard.html og system_prompt.txt. Kan løsningen justeres til andre formål. Eksempelvis
- Lav en træningsplan til mig
- Anbefal nogle film og tv-serier
- Hvilken X vil være bedst at købe, udfra mine behov

BIN
requirements.txt Normal file

Binary file not shown.

51
system_prompt.txt Normal file
View File

@@ -0,0 +1,51 @@
### LANGUAGE INSTRUCTION ###
- You must generate the entire response (Meal Plan, Matrix, Shopping List, and Recipes) in {output_language}.
- Use local culinary terms appropriate for {output_language} (e.g., if Danish, respond only in danish).
### MAIN PRIORITY ###
You are a professional nutritionist and meal planner. You only speak in a structured format that a machine can parse.
### CORE GOALS ###
1. Generate a 4-day DINNER-ONLY meal plan. Do not include breakfast or lunch.
2. The "Matrix" must show which ingredients from the shopping list are used in which dinners. Use 'X' for matches. The dishes are columns. The ingredients are the rows.
3. Recipes must be concise and step-by-step.
4. All recipes and shopping lists must use metric units
### TECHNICAL FORMATTING (STRICT) ###
You must wrap your response in these exact tags. Do not add any text outside of them.
[[DESCRIPTION_START]]
A 3-sentence summary of the plan. Highligt what makes this plan special, the key dishes and ingredients
[[DESCRIPTION_END]]
[[SUMMARY_START]]
[Insert 4-day dinner-only meal plan table]
### INGREDIENT USAGE MATRIX
| Ingredient | Day 1 | Day 2 | Day 3 | Day 4 |
| :--- | :---: | :---: | :---: | :---: |
| Ingredient Name | X | | X | X |
(Ensure you use the exact Markdown table format above with pipes and alignment colons)
[[SUMMARY_END]]
[[SHOPPING_START]]
[Consolidated Shopping List here]
[[SHOPPING_END]]
[[RECIPES_START]]
[Simple Recipes here]
[[RECIPES_END]]
#### PARAMETERS ###
- People: {people_count}
- Budget: {budget_level}
- Max Time: {max_cooking_time} minutes
- Cooking Skill: {skill_level}
- Dietary Focus: {dietary_focus}
- Diet Preference: {dietary_preference}
- Strategy: {meal_strategy}
### FRIDGE CLEAR-OUT (HIGH PRIORITY) ###
- Use these ingredients already in the user's kitchen: {fridge_items}
- Instruction: Generate recipes using these items.

87
templates/base.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Madplaner</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body class="bg-gray-50 text-gray-900 font-sans">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="max-w-5xl mx-auto mt-4 p-4 rounded-xl {% if category == 'error' %}bg-red-100 text-red-700 border border-red-200{% else %}bg-green-100 text-green-700 border border-green-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-stone-100 no-print">
<div class="max-w-6xl mx-auto px-6 h-20 flex items-center justify-between">
<a href="/" class="flex items-center gap-2 group">
<div class="w-8 h-8 bg-emerald-800 rounded-lg flex items-center justify-center transition-transform group-hover:rotate-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<span class="text-xl text-emerald-800 italic font-serif">Madplaner</span>
</a>
<div class="hidden md:flex items-center gap-8">
{% if current_user.is_authenticated %}
<a href="/dashboard" class="text-sm font-medium text-stone-600 hover:text-emerald-800 transition-colors">Dashboard</a>
<div class="h-4 w-px bg-stone-200"></div>
<div class="flex items-center gap-4">
<span class="text-sm text-stone-400 font-light">Hej, {{ current_user.username }}</span>
<a href="/logout" class="text-sm font-medium bg-stone-100 text-stone-900 px-4 py-2 rounded-full hover:bg-stone-200 transition-all">Sign Out</a>
</div>
{% else %}
<a href="/login" class="text-sm font-medium text-stone-600 hover:text-stone-900">Login</a>
<a href="/register" class="text-sm font-medium bg-emerald-800 text-white px-6 py-2.5 rounded-full hover:bg-emerald-900 shadow-sm transition-all">Join Free</a>
{% endif %}
</div>
<div class="md:hidden text-stone-900">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</div>
</div>
</nav>
<main class="container mx-auto p-6">
{% block content %}{% endblock %}
</main>
</body>
<script>
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
// Nordic Palette for Toasts
const styles = type === 'success'
? 'bg-white border-emerald-500 text-stone-800'
: 'bg-white border-rose-500 text-stone-800';
const icon = type === 'success'
? '<span class="text-emerald-500 text-lg">✓</span>'
: '<span class="text-rose-500 text-lg">✕</span>';
toast.className = `toast-animate-in pointer-events-auto flex items-center gap-4 px-6 py-4 rounded-2xl shadow-xl border-l-4 ${styles} min-w-[300px]`;
toast.innerHTML = `
${icon}
<p class="text-sm font-medium tracking-tight">${message}</p>
`;
container.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.replace('toast-animate-in', 'toast-animate-out');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
</script>
</html>

204
templates/dashboard.html Normal file
View File

@@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-6xl mx-auto px-6 py-12">
<div class="mb-12">
<h1 class="text-4xl font-light text-stone-900 tracking-tight">
Hej, <span class="font-serif italic text-emerald-800">{{ current_user.username }}</span>
</h1>
<p class="text-stone-500 font-light mt-2">Lets design a menu for the days ahead.</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
<div class="lg:col-span-2">
<div class="mb-6 flex items-center gap-3 px-1">
<div class="w-8 h-8 bg-emerald-50 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-emerald-700" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<h2 class="text-xl font-medium text-stone-800 tracking-tight">Personal preferences</h2>
</div>
<div class="bg-white p-8 md:p-10 rounded-[2rem] shadow-sm border border-stone-100">
<form action="{{ url_for('generate') }}" method="POST" class="space-y-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
{% set fields = [
('people_count', 'Number of People', [('1 adult', '1 adult'), ('2 adults', '2 adults'), ('Family
of 4', 'Family of 4')]),
('budget_level', 'Budget Level', [('Low', 'Low'), ('Medium', 'Medium'), ('High', 'High')]),
('max_cooking_time', 'Max Cooking Time', [('20', '20 min'), ('30', '30 min'), ('45', '45 min'),
('60', '60 min')]),
('skill_level', 'Cooking Skill', [('Beginner (simple steps)', 'Beginner'), ('Intermediate
(comfortable with techniques)', 'Intermediate'), ('Expert (advanced methods)', 'Advanced')]),
('dietary_preference', 'Dietary Preference', [('Standard (Omnivore)', 'Standard'),
('Vegetarian', 'Vegetarian'), ('Vegan', 'Vegan'), ('Pescatarian', 'Pescatarian')]),
('dietary_focus', 'Dietary Focus', [('Balanced', 'Balanced'), ('High Protein', 'High Protein'),
('Low Carb', 'Low Carb'), ('Kid-Friendly', 'Kid-Friendly')])
] %}
{% for name, label, options in fields %}
<div>
<label class="block text-xs font-semibold text-stone-400 uppercase tracking-widest mb-3">{{
label }}</label>
<select name="{{ name }}"
class="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-stone-700 focus:ring-2 focus:ring-emerald-800/20 focus:border-emerald-800 outline-none transition-all appearance-none cursor-pointer">
{% for val, display in options %}
<option value="{{ val }}" {% if val=='2 adults' or val=='Medium' or val=='30' or
val=='Intermediate (comfortable with techniques)' or val=='Standard (Omnivore)' or
val=='Balanced' %}selected{% endif %}>{{ display }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
</div>
<div class="pt-8 border-t border-stone-50 space-y-6">
<div>
<label
class="block text-xs font-semibold text-stone-400 uppercase tracking-widest mb-3">Meal
Strategy</label>
<select name="meal_strategy"
class="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-stone-700 focus:ring-2 focus:ring-emerald-800/20 focus:border-emerald-800 outline-none transition-all appearance-none cursor-pointer">
<option value="Maximize ingredient reuse" selected>Maximize Reuse (Zero Waste)</option>
<option value="Variety-focused">Maximum Variety</option>
<option value="Quick & Easy">Quick & Easy</option>
</select>
</div>
<div>
<label
class="block text-xs font-semibold text-stone-400 uppercase tracking-widest mb-3">Fridge
Clear-out</label>
<input type="text" name="fridge_items" placeholder="e.g. 2 Carrots, half a cabbage..."
class="w-full px-4 py-4 border-2 border-emerald-50/50 rounded-xl bg-emerald-50/30 text-stone-700 focus:border-emerald-800/30 focus:bg-white outline-none transition-all placeholder:text-stone-300">
</div>
<div>
<label
class="block text-xs font-semibold text-stone-400 uppercase tracking-widest mb-3">Output
Language</label>
<div class="flex gap-4">
<label class="relative flex-1 cursor-pointer group">
<input type="radio" name="output_language" value="English" checked
class="peer sr-only">
<div
class="flex items-center justify-center gap-3 p-3 bg-stone-50 border border-stone-200 rounded-xl peer-checked:border-emerald-800 peer-checked:bg-emerald-50 transition-all">
<span class="text-2xl">🇬🇧</span>
<span
class="text-sm font-medium text-stone-600 group-hover:text-stone-900">English</span>
</div>
</label>
<label class="relative flex-1 cursor-pointer group">
<input type="radio" name="output_language" value="Danish" class="peer sr-only">
<div
class="flex items-center justify-center gap-3 p-3 bg-stone-50 border border-stone-200 rounded-xl peer-checked:border-emerald-800 peer-checked:bg-emerald-50 transition-all">
<span class="text-2xl">🇩🇰</span>
<span
class="text-sm font-medium text-stone-600 group-hover:text-stone-900">Dansk</span>
</div>
</label>
</div>
</div>
</div>
<button type="submit" id="submitBtn"
class="w-full bg-emerald-800 text-white font-medium py-5 rounded-2xl shadow-lg hover:bg-emerald-700 hover:-translate-y-0.5 transition-all duration-300 flex items-center justify-center gap-3">
<span id="btnText">Generate Personalized Plan</span>
<div id="btnLoader" class="hidden">
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
</button>
</form>
</div>
</div>
<div class="lg:col-span-1">
<div class="mb-6 flex items-center gap-3 px-1">
<div class="w-8 h-8 bg-stone-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-stone-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-xl font-medium text-stone-800 tracking-tight">Recent Activity</h2>
</div>
<div class="space-y-4 max-h-[800px] overflow-y-auto pr-2 custom-scrollbar">
{% for plan in plans %}
<div
class="bg-white p-6 rounded-2xl border border-stone-100 shadow-sm hover:shadow-md hover:border-emerald-100 transition-all group">
<h3 class="text-stone-800 font-medium leading-snug group-hover:text-emerald-800 transition-colors">
{{ plan.description }}</h3>
<div class="flex justify-between items-center mt-6">
<span class="text-[10px] font-bold text-stone-300 uppercase tracking-widest">Plan #{{ plan.id
}}</span>
<a href="{{ url_for('view_plan', plan_id=plan.id) }}"
class="text-xs font-semibold text-emerald-800 flex items-center gap-1 hover:gap-2 transition-all">
View Plan <span></span>
</a>
</div>
</div>
{% else %}
<div class="text-center py-16 bg-stone-50/50 rounded-3xl border-2 border-dashed border-stone-100">
<p class="text-stone-400 font-light italic text-sm px-4">Your culinary history will appear here.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<style>
/* Subtle scrollbar for history sidebar */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e7e5e4;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #d6d3d1;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 1. Identify the form and button elements
const planForm = document.querySelector('form[action="{{ url_for("generate") }}"]');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const btnLoader = document.getElementById('btnLoader');
// 2. Add submission logic
if (planForm && submitBtn) {
planForm.addEventListener('submit', function () {
// Disable button to prevent double-clicks
submitBtn.disabled = true;
// Trigger Nordic-style loading animations
submitBtn.classList.add('opacity-80', 'cursor-not-allowed', 'animate-pulse');
// Update text and show the SVG spinner
if (btnText) btnText.innerText = 'Generating your plan...';
if (btnLoader) btnLoader.classList.remove('hidden');
});
}
});
</script>
{% endblock %}

111
templates/index.html Normal file
View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-6xl mx-auto px-6 py-12 md:py-24">
<div class="flex flex-col items-center text-center space-y-8">
<!-- <div class="inline-flex items-center gap-2 bg-stone-100 text-stone-600 px-4 py-1.5 rounded-full text-xs font-medium tracking-widest uppercase border border-stone-200">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
MiMo-V2 Intelligence
</div> -->
<h1 class="text-5xl md:text-7xl font-light text-stone-900 leading-tight tracking-tight">
Dinner, simplified <br>
<span class="font-serif italic text-emerald-800">by design.</span>
</h1>
<p class="max-w-xl text-lg text-stone-500 font-light leading-relaxed">
A minimalist approach to meal planning. Clear out your fridge, reduce waste, and regain your evenings with AI-tailored dinner plans.
</p>
<div class="pt-4">
{% if current_user.is_authenticated %}
<a href="/dashboard" class="bg-stone-900 text-stone-50 px-10 py-4 rounded-full font-medium transition-all hover:bg-stone-800 hover:shadow-xl active:scale-95 flex items-center gap-3">
Open Dashboard
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
{% else %}
<div class="flex flex-col sm:flex-row gap-4 items-center">
<a href="/register" class="bg-emerald-800 text-white px-10 py-4 rounded-full font-medium shadow-sm hover:bg-emerald-900 transition-all active:scale-95">
Get Started
</a>
<a href="/login" class="text-stone-600 hover:text-stone-900 px-8 py-4 font-medium transition-colors">
Sign In
</a>
</div>
{% endif %}
</div>
</div>
<div class="mt-32 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 border-t border-stone-100 pt-16">
<div class="group space-y-4">
<div class="w-12 h-12 bg-amber-50 rounded-2xl flex items-center justify-center text-amber-700 group-hover:bg-amber-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-stone-800 tracking-tight">Purely Personal</h3>
<p class="text-stone-500 text-sm leading-relaxed font-light">
Whether you're Keto, Vegan, want kid-friendly meals or gourmet meals, every recipe is filtered for your unique needs.
</p>
</div>
<div class="group space-y-4">
<div class="w-12 h-12 bg-emerald-50 rounded-2xl flex items-center justify-center text-emerald-700 group-hover:bg-emerald-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-stone-800 tracking-tight">Zero Waste</h3>
<p class="text-stone-500 text-sm leading-relaxed font-light">
Our "Fridge Clear-out" logic prioritizes what you already have, reducing food waste and grocery bills.
</p>
</div>
<div class="group space-y-4">
<div class="w-12 h-12 bg-orange-50 rounded-2xl flex items-center justify-center text-orange-700 group-hover:bg-orange-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 class="text-lg font-semibold text-stone-800 tracking-tight">Smart Matrix</h3>
<p class="text-stone-500 text-sm leading-relaxed font-light">
Instantly visualize how ingredients are reused across your week with our automated usage matrix.
</p>
</div>
<div class="group space-y-4">
<div class="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-700 group-hover:bg-blue-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-stone-800 tracking-tight">Time-Conscious</h3>
<p class="text-stone-500 text-sm leading-relaxed font-light">
From 20-minute rapid meals to gourmet weekend dinners, we plan around your schedule.
</p>
</div>
</div>
<footer>
{% if source_link %}
<div class="source-link">
<a href="{{ source_link }}" target="_blank" rel="noopener">
View Source Code
</a>
</div>
{% endif %}
</footer>
</div>
<style>
/* Adding a soft background gradient for that 'Nordic Fog' feel */
body {
background-color: #fdfdfc;
background-image: radial-gradient(#e5e7eb 0.5px, transparent 0.5px);
background-size: 40px 40px;
}
</style>
{% endblock %}

18
templates/login.html Normal file
View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg mt-10">
<h2 class="text-2xl font-bold mb-6">Welcome Back</h2>
<form method="POST">
<div class="mb-4">
<label class="block text-sm font-medium mb-1">Username</label>
<input type="text" name="username" class="w-full p-3 border rounded-lg" required>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-1">Password</label>
<input type="password" name="password" class="w-full p-3 border rounded-lg" required>
</div>
<button type="submit" class="w-full bg-green-600 text-white py-3 rounded-lg font-bold">Login</button>
</form>
<p class="mt-4 text-center text-sm">Don't have an account? <a href="/register" class="text-green-600">Sign up</a></p>
</div>
{% endblock %}

144
templates/plan_result.html Normal file
View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-8 no-print">
<a href="{{ url_for('dashboard') }}" class="text-gray-500 hover:text-green-600 transition flex items-center gap-2">
<span class="text-xl"></span> Dashboard
</a>
<div class="flex gap-3">
<button onclick="window.print()" class="bg-black text-white px-5 py-2 rounded-xl font-bold text-sm shadow-lg hover:bg-gray-800 transition">
Print Full Plan 🖨️
</button>
</div>
</div>
<div class="flex border-b border-gray-200 mb-8 no-print sticky top-0 bg-gray-50 z-10 py-2 overflow-x-auto gap-2">
<button onclick="showTab('summary')" class="tab-btn active-tab px-6 py-2 font-bold text-green-600 border-b-2 border-green-600 whitespace-nowrap">Overview</button>
<button onclick="showTab('shopping')" class="tab-btn px-6 py-2 text-gray-500 hover:text-green-600 whitespace-nowrap">Shopping List 🛒</button>
<button onclick="showTab('recipes')" class="tab-btn px-6 py-2 text-gray-500 hover:text-green-600 whitespace-nowrap">Recipes 👨‍🍳</button>
</div>
<div class="bg-white p-8 rounded-3xl shadow-xl border border-gray-100 min-h-[60vh]">
<div id="summary" class="tab-content prose prose-slate max-w-none">
{{ plan.summary | safe }}
</div>
<div id="shopping" class="tab-content hidden prose prose-slate max-w-none">
{{ plan.shopping | safe }}
</div>
<div id="recipes" class="tab-content hidden prose prose-slate max-w-none">
{{ plan.recipes | safe }}
</div>
</div>
</div>
<style>
/* Styling for the interactive shopping list */
.active-tab { color: #059669 !important; border-bottom: 2px solid #059669 !important; }
input[type="checkbox"]:checked + .checkbox-text {
text-decoration: line-through;
color: #9ca3af;
transition: all 0.2s ease;
}
#shopping ul { list-style-type: none; padding-left: 0; }
#shopping li { margin-bottom: 0.5rem; }
@media print {
.no-print { display: none !important; }
.tab-content { display: block !important; margin-bottom: 50px; page-break-after: always; }
.bg-white { box-shadow: none !important; border: none !important; }
}
</style>
<style>
/* 1. Your existing tab and checkbox styles */
.active-tab { color: #059669 !important; border-bottom: 2px solid #059669 !important; }
input[type="checkbox"]:checked + .checkbox-text {
text-decoration: line-through;
color: #9ca3af;
transition: all 0.2s ease;
}
#shopping ul { list-style-type: none; padding-left: 0; }
#shopping li { margin-bottom: 0.5rem; }
/* 2. PASTE THE NEW TABLE STYLES HERE */
.prose table {
width: 100% !important;
border-collapse: collapse !important;
border: 1px solid #e5e7eb !important;
margin-top: 1.5rem !important;
border-radius: 0.75rem !important;
overflow: hidden !important;
}
.prose th {
background-color: #f8fafc !important;
padding: 0.75rem !important;
border: 1px solid #e5e7eb !important;
text-transform: uppercase !important;
font-size: 0.75rem !important;
letter-spacing: 0.05em !important;
}
.prose td {
padding: 0.75rem !important;
border: 1px solid #e5e7eb !important;
text-align: left !important;
}
.prose td:has(span.matrix-x),
.prose td:empty {
text-align: center !important;
}
.tab-content {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 3. Your existing print logic stays at the bottom */
@media print {
.no-print { display: none !important; }
.tab-content { display: block !important; margin-bottom: 50px; page-break-after: always; }
.bg-white { box-shadow: none !important; border: none !important; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
// 1. Tab Switching Logic
function showTab(tabId) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active-tab', 'font-bold', 'text-green-600', 'border-b-2', 'border-green-600'));
document.getElementById(tabId).classList.remove('hidden');
event.currentTarget.classList.add('active-tab', 'font-bold', 'text-green-600', 'border-b-2', 'border-green-600');
}
// 2. Render Markdown and Add Interactive Elements
window.onload = function() {
// Render each section
document.querySelectorAll('.tab-content').forEach(div => {
const rawContent = div.textContent.trim();
div.innerHTML = marked.parse(rawContent);
});
// Add checkboxes to shopping list
const listItems = document.querySelectorAll('#shopping li');
listItems.forEach(item => {
const text = item.innerHTML;
item.innerHTML = `
<label class="flex items-center space-x-3 cursor-pointer p-2 hover:bg-gray-50 rounded-lg group">
<input type="checkbox" class="w-5 h-5 rounded border-gray-300 text-green-600 focus:ring-green-500 transition cursor-pointer">
<span class="text-gray-700 checkbox-text">${text}</span>
</label>
`;
});
};
</script>
{% endblock %}

66
templates/register.html Normal file
View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg mt-10">
<div class="text-center mb-8">
<h2 class="text-3xl font-extrabold text-gray-900">Opret bruger</h2>
<p class="text-gray-500 mt-2">Få madplaner tilpasset dine ønsker i dag.</p>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" id="registerForm">
<div class="mb-4">
<label class="block text-sm font-semibold mb-2 text-gray-700">Username</label>
<input type="text" name="username" placeholder="e.g. HealthyEats2024"
class="w-full p-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:outline-none transition" required>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold mb-2 text-gray-700">Password</label>
<input type="password" id="password" name="password" placeholder="••••••••"
class="w-full p-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:outline-none transition" required>
</div>
<div class="mb-6">
<label class="block text-sm font-semibold mb-2 text-gray-700">Confirm Password</label>
<input type="password" id="confirm_password" placeholder="••••••••"
class="w-full p-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:outline-none transition" required>
<p id="error-msg" class="text-red-500 text-xs mt-2 hidden">Passwords do not match!</p>
</div>
<button type="submit" id="regBtn" class="w-full bg-green-600 hover:bg-green-500 text-white py-3 rounded-xl font-bold shadow-md transition-all active:scale-95">
Create Account
</button>
</form>
<div class="mt-8 pt-6 border-t border-gray-100 text-center">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="text-green-600 font-bold hover:underline">Log in</a>
</p>
</div>
</div>
<script>
const form = document.getElementById('registerForm');
const password = document.getElementById('password');
const confirm = document.getElementById('confirm_password');
const errorMsg = document.getElementById('error-msg');
form.onsubmit = function(e) {
if (password.value !== confirm.value) {
e.preventDefault();
errorMsg.classList.remove('hidden');
return false;
}
};
</script>
{% endblock %}