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