Compare commits
10 Commits
98fe8493e7
...
7a38fb95d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
# 🧁 Butter Garden
|
||||||
Personal Recipe Database
|
|
||||||
|
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)
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
password_hash = db.Column(db.String(120), nullable=False)
|
password_hash = db.Column(db.String(120), nullable=False)
|
||||||
is_admin = db.Column(db.Boolean, default=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):
|
class Recipe(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
ingredients = db.Column(db.Text, nullable=False)
|
||||||
instructions = 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
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
@@ -54,19 +63,25 @@ def recipe_detail(recipe_id):
|
|||||||
recipe = Recipe.query.get_or_404(recipe_id)
|
recipe = Recipe.query.get_or_404(recipe_id)
|
||||||
return render_template('recipe_detail.html', recipe=recipe)
|
return render_template('recipe_detail.html', recipe=recipe)
|
||||||
|
|
||||||
@app.route('/admin', methods=['GET'])
|
@app.route('/admin')
|
||||||
@login_required
|
@login_required
|
||||||
def admin():
|
def admin():
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin and not current_user.can_add_recipes:
|
||||||
flash('Access denied.')
|
flash('Access denied.')
|
||||||
return redirect(url_for('index'))
|
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)
|
return render_template('admin.html', recipes=recipes)
|
||||||
|
|
||||||
@app.route('/admin/recipe/add', methods=['GET', 'POST'])
|
@app.route('/admin/recipe/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_recipe():
|
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.')
|
flash('Access denied.')
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@@ -74,23 +89,29 @@ def add_recipe():
|
|||||||
recipe = Recipe(
|
recipe = Recipe(
|
||||||
title=request.form['title'],
|
title=request.form['title'],
|
||||||
ingredients=request.form['ingredients'],
|
ingredients=request.form['ingredients'],
|
||||||
instructions=request.form['instructions']
|
instructions=request.form['instructions'],
|
||||||
|
user_id=current_user.id
|
||||||
)
|
)
|
||||||
db.session.add(recipe)
|
db.session.add(recipe)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Recipe added successfully!')
|
flash('Recipe added successfully!')
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for('admin'))
|
||||||
|
return render_template('add_recipe.html')
|
||||||
return render_template('recipe_form.html')
|
|
||||||
|
|
||||||
@app.route('/admin/recipe/edit/<int:recipe_id>', methods=['GET', 'POST'])
|
@app.route('/admin/recipe/edit/<int:recipe_id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_recipe(recipe_id):
|
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.')
|
flash('Access denied.')
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
recipe = Recipe.query.get_or_404(recipe_id)
|
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':
|
if request.method == 'POST':
|
||||||
recipe.title = request.form['title']
|
recipe.title = request.form['title']
|
||||||
recipe.ingredients = request.form['ingredients']
|
recipe.ingredients = request.form['ingredients']
|
||||||
@@ -98,22 +119,97 @@ def edit_recipe(recipe_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Recipe updated successfully!')
|
flash('Recipe updated successfully!')
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for('admin'))
|
||||||
|
return render_template('edit_recipe.html', recipe=recipe)
|
||||||
return render_template('recipe_form.html', recipe=recipe)
|
|
||||||
|
|
||||||
@app.route('/admin/recipe/delete/<int:recipe_id>')
|
@app.route('/admin/recipe/delete/<int:recipe_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def delete_recipe(recipe_id):
|
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.')
|
flash('Access denied.')
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
recipe = Recipe.query.get_or_404(recipe_id)
|
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.delete(recipe)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Recipe deleted successfully!')
|
flash('Recipe deleted successfully!')
|
||||||
return redirect(url_for('admin'))
|
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
|
# HTMX Endpoints
|
||||||
@app.route('/recipes/search')
|
@app.route('/recipes/search')
|
||||||
def search_recipes():
|
def search_recipes():
|
||||||
@@ -127,7 +223,7 @@ def search_recipes():
|
|||||||
@app.route('/recipe/<int:recipe_id>/delete', methods=['DELETE'])
|
@app.route('/recipe/<int:recipe_id>/delete', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_recipe_htmx(recipe_id):
|
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
|
return 'Unauthorized', 403
|
||||||
|
|
||||||
recipe = Recipe.query.get_or_404(recipe_id)
|
recipe = Recipe.query.get_or_404(recipe_id)
|
||||||
@@ -135,13 +231,19 @@ def delete_recipe_htmx(recipe_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return '', 200
|
return '', 200
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check():
|
||||||
|
return {'status': 'healthy'}, 200
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
user = User.query.filter_by(username=request.form['username']).first()
|
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)
|
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')
|
flash('Invalid username or password')
|
||||||
return render_template('login.html')
|
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
|
||||||
57
init_db.py
57
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 werkzeug.security import generate_password_hash
|
||||||
from dotenv import load_dotenv
|
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
# Create tables
|
||||||
|
db.drop_all()
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Check if admin user already exists
|
# Create admin user
|
||||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
admin = User(
|
||||||
admin = User.query.filter_by(username=admin_username).first()
|
username=os.getenv('ADMIN_USERNAME', 'admin'),
|
||||||
if not admin:
|
is_admin=True,
|
||||||
admin = User(
|
can_add_recipes=True
|
||||||
username=admin_username,
|
)
|
||||||
password_hash=generate_password_hash(os.getenv('ADMIN_PASSWORD', 'change-this-password')),
|
admin.set_password(os.getenv('ADMIN_PASSWORD', 'admin'))
|
||||||
is_admin=True
|
db.session.add(admin)
|
||||||
)
|
|
||||||
db.session.add(admin)
|
# Create a recipe manager user
|
||||||
db.session.commit()
|
manager = User(
|
||||||
print("Admin user created successfully!")
|
username='recipe_manager',
|
||||||
else:
|
can_add_recipes=True
|
||||||
print("Admin user already exists.")
|
)
|
||||||
|
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__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
init_db()
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ Flask-Login==0.6.3
|
|||||||
Flask-WTF==1.2.1
|
Flask-WTF==1.2.1
|
||||||
Werkzeug==3.0.1
|
Werkzeug==3.0.1
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|||||||
@@ -1,44 +1,96 @@
|
|||||||
|
/* Light theme variables */
|
||||||
:root {
|
:root {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--primary-color: #9B4819; /* Warm terracotta */
|
--primary-color: #9B4819;
|
||||||
--secondary-color: #D68C45; /* Muted orange */
|
--primary-color-dark: #7A3914;
|
||||||
--success-color: #8C9B4C; /* Olive green */
|
--secondary-color: #D68C45;
|
||||||
--danger-color: #C23B22; /* Warm red */
|
--secondary-color-dark: #B37338;
|
||||||
--background-color: #FDF6EC; /* Warm off-white */
|
--background-color: #FDF6EC;
|
||||||
--text-color: #2C1810; /* Deep brown */
|
--surface-color: #FFFFFF;
|
||||||
|
--text-color: #2C1810;
|
||||||
/* Recipe Card */
|
--text-color-light: #5C4037;
|
||||||
--recipe-card-bg: #ffffff;
|
--border-color: #E8D5C4;
|
||||||
--recipe-card-border: #E8D5C4; /* Light warm beige */
|
--error-color: #DC3545;
|
||||||
--recipe-card-shadow: rgba(155, 72, 25, 0.1);
|
--error-color-dark: #BD2130;
|
||||||
--recipe-card-title-color: #9B4819; /* Same as primary */
|
--success-color: #28A745;
|
||||||
--recipe-card-text-color: #594D46; /* Muted brown */
|
--link-color: #9B4819;
|
||||||
|
|
||||||
/* 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 */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
--spacing-unit: 1rem;
|
--spacing-unit: 1rem;
|
||||||
--container-max-width: 1200px;
|
--container-max-width: 1200px;
|
||||||
|
|
||||||
/* Typography */
|
/* 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-brand: "Sour Gummy", cursive;
|
||||||
--font-family-base: "Sour Gummy", system-ui, -apple-system, sans-serif;
|
--font-family-base: "Sour Gummy", system-ui, -apple-system, sans-serif;
|
||||||
--font-size-base: 1rem;
|
--font-size-base: 1rem;
|
||||||
--line-height-base: 1.5;
|
--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: 0.25rem;
|
||||||
--border-radius-lg: 0.5rem;
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition-base: all 0.2s ease-in-out;
|
--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 */
|
/* Reset and base styles */
|
||||||
@@ -54,6 +106,7 @@ body {
|
|||||||
line-height: var(--line-height-base);
|
line-height: var(--line-height-base);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
@@ -172,29 +225,16 @@ body {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: calc(var(--spacing-unit) * 0.5);
|
margin-right: calc(var(--spacing-unit) * 0.5);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:last-child {
|
.btn:last-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn:hover {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color-dark);
|
||||||
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 grid layout */
|
||||||
@@ -262,7 +302,7 @@ body {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.back-button {
|
.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;
|
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 Queries */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.layout {
|
.layout {
|
||||||
@@ -341,7 +413,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-desktop-width);
|
||||||
border-right: 1px solid var(--sidebar-border);
|
border-right: 1px solid var(--sidebar-border);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -371,7 +443,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
margin-left: var(--sidebar-width);
|
|
||||||
padding: calc(var(--spacing-unit) * 2);
|
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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="container">
|
||||||
<h2>Recipe Management</h2>
|
<div class="admin-header">
|
||||||
<a href="{{ url_for('add_recipe') }}" class="btn btn-success">Add New Recipe</a>
|
<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>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<style>
|
||||||
<table class="table table-striped">
|
.admin-header {
|
||||||
<thead>
|
display: flex;
|
||||||
<tr>
|
justify-content: space-between;
|
||||||
<th>Title</th>
|
align-items: center;
|
||||||
<th>Actions</th>
|
margin-bottom: 2rem;
|
||||||
</tr>
|
}
|
||||||
</thead>
|
|
||||||
<tbody>
|
.admin-actions {
|
||||||
{% for recipe in recipes %}
|
display: flex;
|
||||||
<tr>
|
gap: 1rem;
|
||||||
<td>{{ recipe.title }}</td>
|
}
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('edit_recipe', recipe_id=recipe.id) }}" class="btn btn-primary btn-sm">Edit</a>
|
.recipes-list {
|
||||||
<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>
|
background: var(--background-color);
|
||||||
</td>
|
border-radius: 8px;
|
||||||
</tr>
|
overflow: hidden;
|
||||||
{% endfor %}
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</div>
|
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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,20 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -19,14 +33,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a class="nav-link" href="{{ url_for('index') }}">Home</a>
|
<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_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 %}
|
{% else %}
|
||||||
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a class="nav-link" href="https://github.com/HardwarePunk/ButterGarden" target="_blank">GitHub 🔗</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -45,5 +60,66 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</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