initial commit
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
FLASK_SECRET_KEY=your-secret-key-here
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
FLASK_ENV=development
|
||||
157
app.py
Normal file
157
app.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, 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 dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, static_url_path='/static', static_folder='static')
|
||||
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'fallback-secret-key-change-this')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///recipes.db'
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'login'
|
||||
|
||||
# Models
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(120), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
class Recipe(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
ingredients = db.Column(db.Text, nullable=False)
|
||||
instructions = db.Column(db.Text, nullable=False)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Routes
|
||||
@app.route('/')
|
||||
def index():
|
||||
recipes = Recipe.query.all()
|
||||
return render_template('index.html', recipes=recipes)
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
query = request.args.get('query', '')
|
||||
recipes = Recipe.query.filter(
|
||||
(Recipe.title.ilike(f'%{query}%')) |
|
||||
(Recipe.ingredients.ilike(f'%{query}%'))
|
||||
).all()
|
||||
return render_template('index.html', recipes=recipes, search_query=query)
|
||||
|
||||
@app.route('/recipe/<int:recipe_id>')
|
||||
def recipe_detail(recipe_id):
|
||||
recipe = Recipe.query.get_or_404(recipe_id)
|
||||
return render_template('recipe_detail.html', recipe=recipe)
|
||||
|
||||
@app.route('/admin', methods=['GET'])
|
||||
@login_required
|
||||
def admin():
|
||||
if not current_user.is_admin:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('index'))
|
||||
recipes = Recipe.query.all()
|
||||
return render_template('admin.html', recipes=recipes)
|
||||
|
||||
@app.route('/admin/recipe/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_recipe():
|
||||
if not current_user.is_admin:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
recipe = Recipe(
|
||||
title=request.form['title'],
|
||||
ingredients=request.form['ingredients'],
|
||||
instructions=request.form['instructions']
|
||||
)
|
||||
db.session.add(recipe)
|
||||
db.session.commit()
|
||||
flash('Recipe added successfully!')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
return render_template('recipe_form.html')
|
||||
|
||||
@app.route('/admin/recipe/edit/<int:recipe_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_recipe(recipe_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
recipe = Recipe.query.get_or_404(recipe_id)
|
||||
if request.method == 'POST':
|
||||
recipe.title = request.form['title']
|
||||
recipe.ingredients = request.form['ingredients']
|
||||
recipe.instructions = request.form['instructions']
|
||||
db.session.commit()
|
||||
flash('Recipe updated successfully!')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
return render_template('recipe_form.html', recipe=recipe)
|
||||
|
||||
@app.route('/admin/recipe/delete/<int:recipe_id>')
|
||||
@login_required
|
||||
def delete_recipe(recipe_id):
|
||||
if not current_user.is_admin:
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
recipe = Recipe.query.get_or_404(recipe_id)
|
||||
db.session.delete(recipe)
|
||||
db.session.commit()
|
||||
flash('Recipe deleted successfully!')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
# HTMX Endpoints
|
||||
@app.route('/recipes/search')
|
||||
def search_recipes():
|
||||
query = request.args.get('query', '')
|
||||
recipes = Recipe.query.filter(
|
||||
(Recipe.title.ilike(f'%{query}%')) |
|
||||
(Recipe.ingredients.ilike(f'%{query}%'))
|
||||
).all()
|
||||
return render_template('partials/recipe_list.html', recipes=recipes)
|
||||
|
||||
@app.route('/recipe/<int:recipe_id>/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_recipe_htmx(recipe_id):
|
||||
if not current_user.is_admin:
|
||||
return 'Unauthorized', 403
|
||||
|
||||
recipe = Recipe.query.get_or_404(recipe_id)
|
||||
db.session.delete(recipe)
|
||||
db.session.commit()
|
||||
return '', 200
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
user = User.query.filter_by(username=request.form['username']).first()
|
||||
if user and check_password_hash(user.password_hash, request.form['password']):
|
||||
login_user(user)
|
||||
return redirect(url_for('admin'))
|
||||
flash('Invalid username or password')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
app.run(debug=True)
|
||||
29
init_db.py
Normal file
29
init_db.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app import app, db, User
|
||||
from werkzeug.security import generate_password_hash
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def init_db():
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Check if admin user already exists
|
||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
admin = User.query.filter_by(username=admin_username).first()
|
||||
if not admin:
|
||||
admin = User(
|
||||
username=admin_username,
|
||||
password_hash=generate_password_hash(os.getenv('ADMIN_PASSWORD', 'change-this-password')),
|
||||
is_admin=True
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print("Admin user created successfully!")
|
||||
else:
|
||||
print("Admin user already exists.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
Werkzeug==3.0.1
|
||||
python-dotenv==1.0.0
|
||||
293
static/css/main.css
Normal file
293
static/css/main.css
Normal file
@@ -0,0 +1,293 @@
|
||||
:root {
|
||||
/* Colors */
|
||||
--primary-color: #9B4819; /* Warm terracotta */
|
||||
--secondary-color: #D68C45; /* Muted orange */
|
||||
--success-color: #8C9B4C; /* Olive green */
|
||||
--danger-color: #C23B22; /* Warm red */
|
||||
--background-color: #FDF6EC; /* Warm off-white */
|
||||
--text-color: #2C1810; /* Deep brown */
|
||||
|
||||
/* Recipe Card */
|
||||
--recipe-card-bg: #ffffff;
|
||||
--recipe-card-border: #E8D5C4; /* Light warm beige */
|
||||
--recipe-card-shadow: rgba(155, 72, 25, 0.1);
|
||||
--recipe-card-title-color: #9B4819; /* Same as primary */
|
||||
--recipe-card-text-color: #594D46; /* Muted brown */
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-bg: #9B4819; /* Primary color */
|
||||
--sidebar-border: #B25F2C; /* Lighter terracotta */
|
||||
--sidebar-brand-color: #ffffff;
|
||||
--sidebar-link-color: rgba(255, 255, 255, 0.9);
|
||||
--sidebar-link-hover-color: #ffffff;
|
||||
--sidebar-link-hover-bg: rgba(214, 140, 69, 0.2); /* Secondary color with opacity */
|
||||
--sidebar-mobile-height: 60px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-unit: 1rem;
|
||||
--container-max-width: 1200px;
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-size-base: 1rem;
|
||||
--line-height-base: 1.5;
|
||||
|
||||
/* Border Radius */
|
||||
--border-radius: 0.25rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-base: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--text-color);
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background-color: var(--sidebar-bg);
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.sidebar .sidebar-content {
|
||||
padding: var(--spacing-unit);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar .brand a {
|
||||
color: var(--sidebar-brand-color);
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
padding: var(--spacing-unit) 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--sidebar-link-color);
|
||||
text-decoration: none;
|
||||
padding: calc(var(--spacing-unit) * 0.5);
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: var(--sidebar-link-hover-color);
|
||||
background-color: var(--sidebar-link-hover-bg);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-unit);
|
||||
}
|
||||
|
||||
/* Recipe Cards */
|
||||
.recipe-card {
|
||||
background-color: var(--recipe-card-bg);
|
||||
border: 1px solid var(--recipe-card-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
margin-bottom: var(--spacing-unit);
|
||||
box-shadow: 0 4px 6px var(--recipe-card-shadow);
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recipe-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 12px var(--recipe-card-shadow);
|
||||
}
|
||||
|
||||
.recipe-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recipe-card .card-title {
|
||||
color: var(--recipe-card-title-color);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
border-bottom: 1px solid rgba(44, 80, 44, 0.3);
|
||||
padding-bottom: calc(var(--spacing-unit) * 0.5);
|
||||
}
|
||||
|
||||
.recipe-card .card-text {
|
||||
color: var(--recipe-card-text-color);
|
||||
margin-bottom: calc(var(--spacing-unit) * 1.5);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: calc(var(--spacing-unit) * 0.5) var(--spacing-unit);
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-right: calc(var(--spacing-unit) * 0.5);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: color-mix(in srgb, var(--primary-color) 85%, white);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: color-mix(in srgb, var(--danger-color) 70%, transparent);
|
||||
color: white;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Recipe grid layout */
|
||||
#recipe-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: calc(var(--spacing-unit) * 1.5);
|
||||
padding: var(--spacing-unit) 0;
|
||||
}
|
||||
|
||||
#recipe-list .col-12 {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: calc(var(--spacing-unit) * 0.5);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: color-mix(in srgb, var(--background-color) 90%, black);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: var(--spacing-unit);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: color-mix(in srgb, var(--secondary-color) 20%, transparent);
|
||||
border: 1px solid var(--secondary-color);
|
||||
}
|
||||
|
||||
/* HTMX specific styles */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media (min-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
border-bottom: none;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar .sidebar-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar .brand a {
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.sidebar .nav-links {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
width: 100%;
|
||||
padding: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
}
|
||||
1
static/js/htmx.min.js
vendored
Normal file
1
static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
30
templates/admin.html
Normal file
30
templates/admin.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Recipe Management</h2>
|
||||
<a href="{{ url_for('add_recipe') }}" class="btn btn-success">Add New Recipe</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for recipe in recipes %}
|
||||
<tr>
|
||||
<td>{{ recipe.title }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_recipe', recipe_id=recipe.id) }}" class="btn btn-primary btn-sm">Edit</a>
|
||||
<a href="{{ url_for('delete_recipe', recipe_id=recipe.id) }}" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete this recipe?')">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
46
templates/base.html
Normal file
46
templates/base.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Recipe Database{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="brand">
|
||||
<a href="{{ url_for('index') }}">Butter Garden</a>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link" href="{{ url_for('index') }}">Home</a>
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a class="nav-link" href="{{ url_for('admin') }}">Admin</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-info">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
templates/index.html
Normal file
21
templates/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<input type="text"
|
||||
name="query"
|
||||
class="form-control"
|
||||
placeholder="Search recipes..."
|
||||
hx-get="{{ url_for('search_recipes') }}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#recipe-list"
|
||||
value="{{ search_query if search_query }}">
|
||||
<div class="htmx-indicator">Searching...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipe-list" class="row">
|
||||
{% include 'partials/recipe_list.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
templates/login.html
Normal file
26
templates/login.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-center mb-4">Login</h2>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
templates/partials/recipe_list.html
Normal file
22
templates/partials/recipe_list.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% for recipe in recipes %}
|
||||
<div class="col">
|
||||
<div class="recipe-card">
|
||||
<h5 class="card-title">{{ recipe.title }}</h5>
|
||||
<p class="card-text">{{ recipe.ingredients.split('\n')[0] }}...</p>
|
||||
<a href="{{ url_for('recipe_detail', recipe_id=recipe.id) }}" class="btn btn-primary">View Recipe</a>
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<button class="btn btn-danger"
|
||||
hx-delete="{{ url_for('delete_recipe_htmx', recipe_id=recipe.id) }}"
|
||||
hx-target="closest .recipe-card"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this recipe?">
|
||||
Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-center">
|
||||
<p>No recipes found.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
25
templates/recipe_detail.html
Normal file
25
templates/recipe_detail.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title mb-4">{{ recipe.title }}</h1>
|
||||
|
||||
<h4 class="mb-3">Ingredients</h4>
|
||||
<div class="mb-4">
|
||||
{% for ingredient in recipe.ingredients.split('\n') %}
|
||||
<div>{{ ingredient }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Instructions</h4>
|
||||
<div class="mb-4">
|
||||
{% for step in recipe.instructions.split('\n') %}
|
||||
<p>{{ step }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Recipes</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
templates/recipe_form.html
Normal file
27
templates/recipe_form.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<h2 class="mb-4">{% if recipe %}Edit{% else %}Add New{% endif %} Recipe</h2>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required value="{{ recipe.title if recipe }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ingredients" class="form-label">Ingredients (one per line)</label>
|
||||
<textarea class="form-control" id="ingredients" name="ingredients" rows="5" required>{{ recipe.ingredients if recipe }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="instructions" class="form-label">Instructions (one step per line)</label>
|
||||
<textarea class="form-control" id="instructions" name="instructions" rows="8" required>{{ recipe.instructions if recipe }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-primary">Save Recipe</button>
|
||||
<a href="{{ url_for('admin') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user