Compare commits

..

10 Commits

Author SHA1 Message Date
hex
7a38fb95d8 mention the dark mode moar features muahahaha 2025-01-18 21:46:46 -08:00
hex
c051b040a0 Add dark mode toggle 2025-01-18 21:38:08 -08:00
hex
c5a243a6b6 Demo, User mgmt 2025-01-18 20:24:02 -08:00
hex
4ca9192bcf add user management 2025-01-18 20:20:21 -08:00
hex
d5ffb88a8d fixed docker db init 2025-01-18 19:02:06 -08:00
hex
66f8d87a44 Added Github link 2025-01-18 18:38:43 -08:00
hex
d504ab5964 add docker containerization 2025-01-18 18:05:21 -08:00
hex
f7deb83cb5 add docker containerization 2025-01-18 18:03:17 -08:00
hex
d257759d6f README better again 2025-01-18 17:37:08 -08:00
hex
fa9c01c8d1 Updated README 2025-01-18 17:35:49 -08:00
14 changed files with 1044 additions and 109 deletions

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_APP=app.py \
FLASK_ENV=production
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gcc \
python3-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application
COPY . .
# Make entrypoint script executable
RUN chmod +x /app/docker-entrypoint.sh
# Create volume for persistent database storage
VOLUME ["/app/instance"]
# Run as non-root user
RUN useradd -m myuser
RUN chown -R myuser:myuser /app
USER myuser
# Expose port
EXPOSE 5000
# Command to run the application
ENTRYPOINT ["/app/docker-entrypoint.sh"]

218
README.md
View File

@@ -1,2 +1,216 @@
# ButterGarden
Personal Recipe Database
# 🧁 Butter Garden
A delightful recipe management application built with Flask, featuring a cozy, warm-toned interface and modern web technologies.
🌟 **[Try the Demo](https://garden.hex.lgbt)** 🌟
## ✨ Features
- 🔍 Browse and search recipes
- 📝 View detailed recipe ingredients and instructions
- 👥 Multi-user support with role-based permissions:
- 👑 Admin users can manage all recipes and users
- 👩‍🍳 Recipe managers can create and manage their own recipes
- 👀 Regular users can browse recipes
- 🎨 Beautiful, responsive design with custom styling
- 🚀 Modern interactions using HTMX
- 🌙 Dark mode support with smooth transitions
- 🎯 System theme detection and persistence
- 🌟 Warm, inviting color scheme
- 🐳 Docker support for easy deployment
## 🛠️ Technology Stack
- **Backend**: Flask + SQLAlchemy
- **Frontend**: HTMX + Custom CSS
- **Database**: SQLite
- **Authentication**: Flask-Login with Role-Based Access
- **Styling**:
- CSS Variables for theming
- Dark/Light mode with system preference detection
- Smooth theme transitions
- Modern CSS Features
- **Deployment**: Docker + Gunicorn
## 🚀 Quick Start with Docker
The easiest way to run Butter Garden is using Docker:
1. Clone the repository:
```bash
git clone https://github.com/HardwarePunk/ButterGarden.git
cd ButterGarden
```
2. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your preferred settings
```
3. Start with Docker Compose:
```bash
docker-compose up -d
```
That's it! Visit `http://localhost:5000` to start using Butter Garden.
## 🐳 Docker Details
### Container Features
- Production-grade Gunicorn server
- Automatic health checks
- Persistent SQLite database volume
- Non-root user for security
- Automatic restarts on failure
### Container Management
**Start the container:**
```bash
docker-compose up -d
```
**View logs:**
```bash
docker-compose logs -f
```
**Stop the container:**
```bash
docker-compose down
```
**Rebuild after changes:**
```bash
docker-compose up -d --build
```
### Docker Configuration
- Port: 5000 (configurable in docker-compose.yml)
- Database location: /app/instance (mounted as volume)
- Environment variables: loaded from .env file
- Health check interval: 30 seconds
## 📝 Manual Installation (Without Docker)
If you prefer to run without Docker:
1. Create and activate a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your preferred settings
```
4. Initialize the database:
```bash
python init_db.py
```
5. Run the development server:
```bash
flask run
```
## 🔒 Environment Variables
Required variables in `.env`:
```bash
FLASK_SECRET_KEY=your-secret-key-here
ADMIN_USERNAME=your-admin-username
ADMIN_PASSWORD=your-secure-password
FLASK_ENV=production # or development
```
## 👥 User Management
Butter Garden supports three types of users:
1. **Admin Users**
- Can manage all recipes
- Can add, edit, and delete users
- Can assign user roles
- Full access to all features
2. **Recipe Managers**
- Can create new recipes
- Can edit and delete their own recipes
- Cannot manage other users
- Access to "My Recipes" dashboard
3. **Regular Users**
- Can browse and view recipes
- Cannot create or edit recipes
- No access to management features
### Default Users
The application comes with two default users after initialization:
1. **Admin User**
- Username: from ADMIN_USERNAME in .env
- Password: from ADMIN_PASSWORD in .env
- Full administrative access
2. **Recipe Manager**
- Username: recipe_manager
- Password: manager123
- Can create and manage their own recipes
To create additional users:
1. Log in as an admin user
2. Click "Manage Users" in the admin panel
3. Click "Add New User"
4. Fill in the username, password, and permissions
## 👩‍💻 Development
The application uses modern CSS features and HTMX for enhanced interactivity:
- CSS Variables for easy theme customization
- Responsive design, mobile friendly
- HTMX for dynamic content updates
- Custom scrollbar styling
- Sticky navigation elements
## 🎨 Customization
The app's appearance can be easily customized by modifying the CSS variables in `static/css/main.css`:
```css
:root {
--primary-color: #9B4819; /* Warm terracotta */
--secondary-color: #D68C45; /* Muted orange */
--background-color: #FDF6EC; /* Warm off-white */
/* ... other variables ... */
}
```
## 📱 Responsive Design
- Desktop: Left sidebar navigation
- Mobile: Top navigation bar
- Adaptive recipe content display
- Optimized spacing and typography
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature-name`
3. Commit your changes: `git commit -am 'Add some feature'`
4. Push to the branch: `git push origin feature-name`
5. Submit a pull request
## 📝 License
This project is licensed under the Unlicense License - see the LICENSE file for details.

128
app.py
View File

@@ -23,12 +23,21 @@ class User(UserMixin, db.Model):
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)
can_add_recipes = db.Column(db.Boolean, default=False)
recipes = db.relationship('Recipe', backref='author', lazy=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Recipe(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
title = db.Column(db.String(100), nullable=False)
ingredients = db.Column(db.Text, nullable=False)
instructions = db.Column(db.Text, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
@login_manager.user_loader
def load_user(user_id):
@@ -54,19 +63,25 @@ 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'])
@app.route('/admin')
@login_required
def admin():
if not current_user.is_admin:
if not current_user.is_admin and not current_user.can_add_recipes:
flash('Access denied.')
return redirect(url_for('index'))
# Admin sees all recipes, others see only their own
if current_user.is_admin:
recipes = Recipe.query.all()
else:
recipes = Recipe.query.filter_by(user_id=current_user.id).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:
if not current_user.is_admin and not current_user.can_add_recipes:
flash('Access denied.')
return redirect(url_for('index'))
@@ -74,23 +89,29 @@ def add_recipe():
recipe = Recipe(
title=request.form['title'],
ingredients=request.form['ingredients'],
instructions=request.form['instructions']
instructions=request.form['instructions'],
user_id=current_user.id
)
db.session.add(recipe)
db.session.commit()
flash('Recipe added successfully!')
return redirect(url_for('admin'))
return render_template('recipe_form.html')
return render_template('add_recipe.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:
if not current_user.is_admin and not current_user.can_add_recipes:
flash('Access denied.')
return redirect(url_for('index'))
recipe = Recipe.query.get_or_404(recipe_id)
# Only allow editing if admin or if user created the recipe
if not current_user.is_admin and recipe.user_id != current_user.id:
flash('You can only edit your own recipes.')
return redirect(url_for('admin'))
if request.method == 'POST':
recipe.title = request.form['title']
recipe.ingredients = request.form['ingredients']
@@ -98,22 +119,97 @@ def edit_recipe(recipe_id):
db.session.commit()
flash('Recipe updated successfully!')
return redirect(url_for('admin'))
return render_template('recipe_form.html', recipe=recipe)
return render_template('edit_recipe.html', recipe=recipe)
@app.route('/admin/recipe/delete/<int:recipe_id>')
@login_required
def delete_recipe(recipe_id):
if not current_user.is_admin:
if not current_user.is_admin and not current_user.can_add_recipes:
flash('Access denied.')
return redirect(url_for('index'))
recipe = Recipe.query.get_or_404(recipe_id)
# Only allow deletion if admin or if user created the recipe
if not current_user.is_admin and recipe.user_id != current_user.id:
flash('You can only delete your own recipes.')
return redirect(url_for('admin'))
db.session.delete(recipe)
db.session.commit()
flash('Recipe deleted successfully!')
return redirect(url_for('admin'))
@app.route('/users')
@login_required
def list_users():
if not current_user.is_admin:
flash('Access denied.')
return redirect(url_for('index'))
users = User.query.all()
return render_template('users.html', users=users)
@app.route('/users/add', methods=['GET', 'POST'])
@login_required
def add_user():
if not current_user.is_admin:
flash('Access denied.')
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
can_add_recipes = 'can_add_recipes' in request.form
if User.query.filter_by(username=username).first():
flash('Username already exists.')
return redirect(url_for('add_user'))
user = User(username=username, can_add_recipes=can_add_recipes)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('User added successfully.')
return redirect(url_for('list_users'))
return render_template('add_user.html')
@app.route('/users/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(id):
if not current_user.is_admin:
flash('Access denied.')
return redirect(url_for('index'))
user = User.query.get_or_404(id)
if request.method == 'POST':
user.username = request.form.get('username')
if request.form.get('password'):
user.set_password(request.form.get('password'))
user.can_add_recipes = 'can_add_recipes' in request.form
db.session.commit()
flash('User updated successfully.')
return redirect(url_for('list_users'))
return render_template('edit_user.html', user=user)
@app.route('/users/<int:id>/delete', methods=['POST'])
@login_required
def delete_user(id):
if not current_user.is_admin:
flash('Access denied.')
return redirect(url_for('index'))
user = User.query.get_or_404(id)
if user.is_admin:
flash('Cannot delete admin user.')
return redirect(url_for('list_users'))
db.session.delete(user)
db.session.commit()
flash('User deleted successfully.')
return redirect(url_for('list_users'))
# HTMX Endpoints
@app.route('/recipes/search')
def search_recipes():
@@ -127,7 +223,7 @@ def search_recipes():
@app.route('/recipe/<int:recipe_id>/delete', methods=['DELETE'])
@login_required
def delete_recipe_htmx(recipe_id):
if not current_user.is_admin:
if not current_user.is_admin and not current_user.can_add_recipes:
return 'Unauthorized', 403
recipe = Recipe.query.get_or_404(recipe_id)
@@ -135,13 +231,19 @@ def delete_recipe_htmx(recipe_id):
db.session.commit()
return '', 200
@app.route('/health')
def health_check():
return {'status': 'healthy'}, 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']):
if user and user.check_password(request.form['password']):
login_user(user)
if user.is_admin or user.can_add_recipes:
return redirect(url_for('admin'))
return redirect(url_for('index'))
flash('Invalid username or password')
return render_template('login.html')

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
web:
build: .
ports:
- "5000:5000"
volumes:
- ./instance:/app/instance
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s

7
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
# Initialize the database using init_db.py
python init_db.py
# Start gunicorn
exec gunicorn -c gunicorn.conf.py app:app

38
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,38 @@
# Gunicorn configuration file
import multiprocessing
# Server socket
bind = "0.0.0.0:5000"
backlog = 2048
# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
worker_connections = 1000
timeout = 30
keepalive = 2
# Logging
accesslog = "-"
errorlog = "-"
loglevel = "info"
# Process naming
proc_name = "butter-garden"
# Server mechanics
daemon = False
pidfile = None
umask = 0
user = None
group = None
tmp_upload_dir = None
# SSL
keyfile = None
certfile = None
# Security
limit_request_line = 4096
limit_request_fields = 100
limit_request_field_size = 8190

View File

@@ -1,29 +1,52 @@
from app import app, db, User
from app import app, db, User, Recipe
from werkzeug.security import generate_password_hash
from dotenv import load_dotenv
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def init_db():
with app.app_context():
# Create tables
db.drop_all()
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:
# Create admin user
admin = User(
username=admin_username,
password_hash=generate_password_hash(os.getenv('ADMIN_PASSWORD', 'change-this-password')),
is_admin=True
username=os.getenv('ADMIN_USERNAME', 'admin'),
is_admin=True,
can_add_recipes=True
)
admin.set_password(os.getenv('ADMIN_PASSWORD', 'admin'))
db.session.add(admin)
# Create a recipe manager user
manager = User(
username='recipe_manager',
can_add_recipes=True
)
manager.set_password('manager123')
db.session.add(manager)
# Add some sample recipes
recipes = [
{
'title': 'Chocolate Chip Cookies',
'ingredients': '2 1/4 cups all-purpose flour\n1 cup butter\n3/4 cup sugar\n2 eggs\n2 cups chocolate chips',
'instructions': '1. Preheat oven to 375°F\n2. Mix ingredients\n3. Drop spoonfuls onto baking sheet\n4. Bake for 10 minutes'
},
{
'title': 'Classic Pancakes',
'ingredients': '1 1/2 cups all-purpose flour\n3 1/2 teaspoons baking powder\n1 teaspoon salt\n1 tablespoon sugar',
'instructions': '1. Mix dry ingredients\n2. Add wet ingredients\n3. Cook on griddle'
}
]
for recipe_data in recipes:
recipe = Recipe(**recipe_data)
db.session.add(recipe)
db.session.commit()
print("Admin user created successfully!")
else:
print("Admin user already exists.")
if __name__ == '__main__':
init_db()

View File

@@ -4,3 +4,4 @@ Flask-Login==0.6.3
Flask-WTF==1.2.1
Werkzeug==3.0.1
python-dotenv==1.0.0
gunicorn==21.2.0

View File

@@ -1,44 +1,96 @@
/* Light theme variables */
: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;
--primary-color: #9B4819;
--primary-color-dark: #7A3914;
--secondary-color: #D68C45;
--secondary-color-dark: #B37338;
--background-color: #FDF6EC;
--surface-color: #FFFFFF;
--text-color: #2C1810;
--text-color-light: #5C4037;
--border-color: #E8D5C4;
--error-color: #DC3545;
--error-color-dark: #BD2130;
--success-color: #28A745;
--link-color: #9B4819;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-unit: 1rem;
--container-max-width: 1200px;
/* Typography */
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, "Open Sans", sans-serif;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-family-brand: "Sour Gummy", cursive;
--font-family-base: "Sour Gummy", system-ui, -apple-system, sans-serif;
--font-size-base: 1rem;
--line-height-base: 1.5;
/* Border Radius */
/* Effects */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--border-radius: 0.25rem;
--border-radius-lg: 0.5rem;
/* Transitions */
--transition-base: all 0.2s ease-in-out;
/* Recipe Card */
--recipe-card-bg: #ffffff;
--recipe-card-border: #E8D5C4;
--recipe-card-shadow: rgba(155, 72, 25, 0.1);
--recipe-card-title-color: #9B4819;
--recipe-card-text-color: #594D46;
/* Sidebar */
--sidebar-bg: #9B4819;
--sidebar-border: #B25F2C;
--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);
--sidebar-mobile-height: 60px;
--sidebar-desktop-width: 240px;
}
/* Dark theme variables */
[data-theme="dark"] {
--primary-color: #FF8B4C;
--primary-color-dark: #E67A3D;
--secondary-color: #FFB07F;
--secondary-color-dark: #E69E70;
--background-color: #1A1A1A;
--surface-color: #2D2D2D;
--text-color: #F5E6D3;
--text-color-light: #D4C3B3;
--border-color: #404040;
--error-color: #FF4D4D;
--error-color-dark: #E63939;
--success-color: #4CAF50;
--link-color: #FF8B4C;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
--recipe-card-bg: #2D2D2D;
--recipe-card-border: #404040;
--recipe-card-shadow: rgba(0, 0, 0, 0.3);
--recipe-card-title-color: #FF8B4C;
--recipe-card-text-color: #D4C3B3;
--sidebar-bg: #2D2D2D;
--sidebar-border: #404040;
--sidebar-brand-color: #F5E6D3;
--sidebar-link-color: rgba(255, 255, 255, 0.9);
--sidebar-link-hover-color: #F5E6D3;
--sidebar-link-hover-bg: rgba(214, 140, 69, 0.2);
}
/* Reset and base styles */
@@ -54,6 +106,7 @@ body {
line-height: var(--line-height-base);
color: var(--text-color);
background-color: var(--background-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Layout */
@@ -172,29 +225,16 @@ body {
text-decoration: none;
display: inline-block;
margin-right: calc(var(--spacing-unit) * 0.5);
background-color: var(--primary-color);
color: white;
}
.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);
.btn:hover {
background-color: var(--primary-color-dark);
}
/* Recipe grid layout */
@@ -262,7 +302,7 @@ body {
@media (min-width: 768px) {
.back-button {
left: calc(var(--sidebar-width) + var(--spacing-unit));
left: calc(var(--sidebar-desktop-width) + var(--spacing-unit));
}
}
@@ -334,6 +374,38 @@ body {
border-bottom: none;
}
/* Theme toggle styles */
.theme-toggle {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--surface-color);
border: 2px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
z-index: 1000;
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: var(--shadow-md);
}
.theme-toggle svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* Media Queries */
@media (min-width: 768px) {
.layout {
@@ -341,7 +413,7 @@ body {
}
.sidebar {
width: var(--sidebar-width);
width: var(--sidebar-desktop-width);
border-right: 1px solid var(--sidebar-border);
border-bottom: none;
height: 100vh;
@@ -371,7 +443,6 @@ body {
}
.main-content {
margin-left: var(--sidebar-width);
padding: calc(var(--spacing-unit) * 2);
}

83
templates/add_user.html Normal file
View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>Add New User</h1>
<form method="POST" class="form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="can_add_recipes">
Can add and edit recipes
</label>
</div>
<div class="form-actions">
<a href="{{ url_for('list_users') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn">Add User</button>
</div>
</form>
</div>
<style>
.form {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
background: var(--background-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: var(--secondary-color-dark);
}
</style>
{% endblock %}

View File

@@ -1,16 +1,24 @@
{% 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="container">
<div class="admin-header">
<h1>{% if current_user.is_admin %}Recipe Management{% else %}My Recipes{% endif %}</h1>
<div class="admin-actions">
{% if current_user.is_admin %}
<a href="{{ url_for('list_users') }}" class="btn">Manage Users</a>
{% endif %}
<a href="{{ url_for('add_recipe') }}" class="btn">Add New Recipe</a>
<a href="{{ url_for('logout') }}" class="btn btn-secondary">Logout</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped">
<div class="recipes-list">
<table>
<thead>
<tr>
<th>Title</th>
<th>Created By</th>
<th>Actions</th>
</tr>
</thead>
@@ -18,13 +26,85 @@
{% 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>{{ recipe.author.username if recipe.author else 'Unknown' }}</td>
<td class="actions">
{% if current_user.is_admin or recipe.user_id == current_user.id %}
<a href="{{ url_for('edit_recipe', recipe_id=recipe.id) }}" class="btn btn-small">Edit</a>
<a href="{{ url_for('delete_recipe', recipe_id=recipe.id) }}" class="btn btn-small btn-danger" onclick="return confirm('Are you sure you want to delete this recipe?')">Delete</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.admin-actions {
display: flex;
gap: 1rem;
}
.recipes-list {
background: var(--background-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
}
th, tr {
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--primary-color);
color: white;
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-danger {
background: var(--error-color);
color: white;
}
.btn-danger:hover {
background: var(--error-color-dark);
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: var(--secondary-color-dark);
}
</style>
{% endblock %}

View File

@@ -7,6 +7,20 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DynaPuff:wght@400..700&family=Sour+Gummy:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<script>
// Immediately set the theme before the page renders
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
</head>
@@ -19,14 +33,15 @@
</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 %}
{% if current_user.is_admin or current_user.can_add_recipes %}
<a class="nav-link" href="{{ url_for('admin') }}">{% if current_user.is_admin %}Recipe Management{% else %}My Recipes{% endif %}</a>
{% endif %}
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
{% endif %}
<a class="nav-link" href="https://github.com/HardwarePunk/ButterGarden" target="_blank">GitHub 🔗</a>
</div>
</div>
</nav>
@@ -45,5 +60,66 @@
{% block content %}{% endblock %}
</main>
</div>
<button class="theme-toggle" aria-label="Toggle dark mode">
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z"/>
</svg>
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="display: none;">
<path d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"/>
</svg>
</button>
<script>
// Theme toggle functionality
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.querySelector('.theme-toggle');
const sunIcon = document.querySelector('.sun-icon');
const moonIcon = document.querySelector('.moon-icon');
const html = document.documentElement;
// Check for saved theme preference
const savedTheme = localStorage.getItem('theme') || 'light';
html.setAttribute('data-theme', savedTheme);
updateIcons(savedTheme);
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateIcons(newTheme);
});
function updateIcons(theme) {
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
// Check system preference on load
if (!localStorage.getItem('theme')) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
updateIcons(theme);
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
const newTheme = e.matches ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
updateIcons(newTheme);
}
});
});
</script>
</body>
</html>

83
templates/edit_user.html Normal file
View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>Edit User: {{ user.username }}</h1>
<form method="POST" class="form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label for="password">New Password (leave blank to keep current)</label>
<input type="password" id="password" name="password">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="can_add_recipes" {% if user.can_add_recipes %}checked{% endif %}>
Can add and edit recipes
</label>
</div>
<div class="form-actions">
<a href="{{ url_for('list_users') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn">Save Changes</button>
</div>
</form>
</div>
<style>
.form {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
background: var(--background-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: var(--secondary-color-dark);
}
</style>
{% endblock %}

98
templates/users.html Normal file
View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>User Management</h1>
<div class="action-bar">
<a href="{{ url_for('add_user') }}" class="btn">Add New User</a>
</div>
<div class="users-list">
<table>
<thead>
<tr>
<th>Username</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>
{% if user.is_admin %}
Admin
{% elif user.can_add_recipes %}
Recipe Manager
{% else %}
Viewer
{% endif %}
</td>
<td class="actions">
{% if not user.is_admin %}
<a href="{{ url_for('edit_user', id=user.id) }}" class="btn btn-small">Edit</a>
<form action="{{ url_for('delete_user', id=user.id) }}" method="POST" class="inline-form">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Are you sure you want to delete this user?')">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
.users-list {
margin-top: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--background-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
th, td {
padding: 1rem;
text-align: left;
}
th, tr {
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--primary-color);
color: white;
}
.actions {
display: flex;
gap: 0.5rem;
}
.inline-form {
display: inline;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-danger {
background: var(--error-color);
color: white;
}
.btn-danger:hover {
background: var(--error-color-dark);
}
</style>
{% endblock %}