Compare commits
11 Commits
98fe8493e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e51e1f0dd2 | |||
| 7a38fb95d8 | |||
| c051b040a0 | |||
| c5a243a6b6 | |||
| 4ca9192bcf | |||
| d5ffb88a8d | |||
| 66f8d87a44 | |||
| d504ab5964 | |||
| f7deb83cb5 | |||
| d257759d6f | |||
| fa9c01c8d1 |
43
Dockerfile
Normal file
43
Dockerfile
Normal 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
218
README.md
@@ -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.
|
||||
|
||||
132
app.py
132
app.py
@@ -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'))
|
||||
recipes = Recipe.query.all()
|
||||
|
||||
# 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)
|
||||
return redirect(url_for('admin'))
|
||||
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
16
docker-compose.yml
Normal 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
7
docker-entrypoint.sh
Executable 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
38
gunicorn.conf.py
Normal 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
|
||||
59
init_db.py
59
init_db.py
@@ -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:
|
||||
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.")
|
||||
|
||||
# Create admin user
|
||||
admin = User(
|
||||
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()
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
83
templates/add_user.html
Normal 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 %}
|
||||
@@ -1,30 +1,110 @@
|
||||
{% 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 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="recipes-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Created By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for recipe in recipes %}
|
||||
<tr>
|
||||
<td>{{ recipe.title }}</td>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
@@ -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 %}
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
||||
{% 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>
|
||||
<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
83
templates/edit_user.html
Normal 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
98
templates/users.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user