Compare commits
6 Commits
4db5084bc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4221e20379 | |||
| 52446f39bb | |||
| 335c34ce64 | |||
| cc28aa9a8e | |||
| b8046c87b9 | |||
| 38db9c242b |
54
.dockerignore
Normal file
54
.dockerignore
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
mealprep
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test and coverage files
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.txt
|
||||||
|
*.coverprofile
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
*.txt
|
||||||
|
IMPLEMENTATION_NOTES.txt
|
||||||
|
SECURITY_REPORT.txt
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Docker files (no need to include in image)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.21-bookworm AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
sqlite3 \
|
||||||
|
libsqlite3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=1 go build -o mealprep -ldflags="-s -w" .
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
sqlite3 \
|
||||||
|
libsqlite3-0 \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app directory and data directory
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/mealprep .
|
||||||
|
|
||||||
|
# Copy static files
|
||||||
|
COPY --from=builder /build/static ./static
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV DB_PATH=/app/data/mealprep.db
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["./mealprep"]
|
||||||
@@ -1,156 +1,123 @@
|
|||||||
ACCOUNT SYSTEM - COMPLETE & SECURE
|
EDIT MEAL FEATURE - IMPLEMENTED
|
||||||
|
|
||||||
=== ✅ FULLY IMPLEMENTED ===
|
=== ✅ NEW FEATURE ===
|
||||||
|
|
||||||
AUTHENTICATION SYSTEM with industry-standard security:
|
You can now EDIT existing meals!
|
||||||
- User registration & login
|
|
||||||
- Secure password hashing (bcrypt cost 12)
|
|
||||||
- Session management with secure tokens
|
|
||||||
- Protected routes with middleware
|
|
||||||
- User data isolation
|
|
||||||
- All security best practices
|
|
||||||
|
|
||||||
=== SECURITY FEATURES ===
|
Click "Edit" button → Modal opens → Make changes → Save
|
||||||
|
|
||||||
✅ Password Security:
|
|
||||||
- Bcrypt hashing (industry standard)
|
|
||||||
- Min 8 characters enforced
|
|
||||||
- Never stored in plain text
|
|
||||||
- Constant-time comparison
|
|
||||||
|
|
||||||
✅ Session Security:
|
|
||||||
- 256-bit cryptographically secure tokens
|
|
||||||
- HttpOnly cookies (XSS protection)
|
|
||||||
- SameSite=Strict (CSRF protection)
|
|
||||||
- 7-day expiry
|
|
||||||
- Deleted on logout
|
|
||||||
|
|
||||||
✅ SQL Injection Prevention:
|
|
||||||
- 100% parameterized queries
|
|
||||||
- No string concatenation
|
|
||||||
- All user input sanitized
|
|
||||||
|
|
||||||
✅ XSS Prevention:
|
|
||||||
- Template auto-escaping
|
|
||||||
- Input length limits
|
|
||||||
- Proper encoding
|
|
||||||
|
|
||||||
✅ User Isolation:
|
|
||||||
- Every query filtered by user_id
|
|
||||||
- Users cannot see others' data
|
|
||||||
- Ownership verified on all operations
|
|
||||||
|
|
||||||
=== DATABASE CHANGES ===
|
|
||||||
|
|
||||||
NEW TABLES:
|
|
||||||
- users (id, email, password_hash, created_at)
|
|
||||||
- sessions (token, user_id, expires_at, created_at)
|
|
||||||
|
|
||||||
UPDATED TABLES (added user_id):
|
|
||||||
- ingredients
|
|
||||||
- meals
|
|
||||||
- week_plan
|
|
||||||
|
|
||||||
ALL QUERIES NOW USER-ISOLATED
|
|
||||||
|
|
||||||
=== NEW FILES ===
|
|
||||||
|
|
||||||
auth/auth.go - Password hashing, tokens, validation
|
|
||||||
auth/middleware.go - Authentication middleware
|
|
||||||
handlers/auth.go - Login, register, logout handlers
|
|
||||||
|
|
||||||
=== HOW IT WORKS ===
|
=== HOW IT WORKS ===
|
||||||
|
|
||||||
1. USER REGISTERS:
|
1. Click "Edit" button on any meal
|
||||||
- Email validated
|
2. Modal dialog opens with form
|
||||||
- Password min 8 chars
|
3. All fields pre-filled with current values:
|
||||||
- Password hashed with bcrypt
|
- Name
|
||||||
- User created in database
|
- Description
|
||||||
- Session created
|
- Type (breakfast/lunch/snack)
|
||||||
- Cookie set
|
- Prep time
|
||||||
- Redirected to home
|
- Image URL
|
||||||
|
- Instructions
|
||||||
|
4. Change what you want
|
||||||
|
5. Click "Save" → Modal closes, meal updates
|
||||||
|
6. Click "Cancel" → Modal closes, no changes
|
||||||
|
|
||||||
2. USER LOGS IN:
|
=== SECURITY ===
|
||||||
- Email & password validated
|
|
||||||
- Password checked against hash
|
|
||||||
- Session created with secure token
|
|
||||||
- HttpOnly cookie set
|
|
||||||
- Redirected to home
|
|
||||||
|
|
||||||
3. PROTECTED ROUTES:
|
✅ User isolation enforced:
|
||||||
- Middleware checks cookie
|
- GetMealByID(userID, mealID) verifies ownership
|
||||||
- Session validated
|
- Users CANNOT edit others' meals
|
||||||
- User ID extracted
|
- Users CANNOT access others' meal data
|
||||||
- Added to request context
|
- UPDATE query filters by user_id
|
||||||
- Handler gets user ID
|
- All queries parameterized (SQL injection safe)
|
||||||
- All DB queries filtered by user_id
|
|
||||||
|
|
||||||
4. USER LOGS OUT:
|
✅ Modal security:
|
||||||
- Session deleted from DB
|
- Closes on click outside
|
||||||
- Cookie cleared
|
- Close button works
|
||||||
- Redirected to login
|
- No data exposed
|
||||||
|
- XSS protected (template escaping)
|
||||||
|
|
||||||
=== ROUTES ===
|
=== UI FEATURES ===
|
||||||
|
|
||||||
PUBLIC:
|
Modal Dialog:
|
||||||
- GET/POST /login
|
- Semi-transparent overlay
|
||||||
- GET/POST /register
|
- Centered white box
|
||||||
- GET /logout
|
- All fields editable
|
||||||
|
- Save button (blue)
|
||||||
|
- Cancel button (gray)
|
||||||
|
- Click outside to close
|
||||||
|
- Clean, professional design
|
||||||
|
|
||||||
PROTECTED (require auth):
|
Buttons:
|
||||||
- / (home)
|
- Edit (orange) - opens modal
|
||||||
- /ingredients, /meals, /week-plan, /grocery-list
|
- Save (blue) - updates meal
|
||||||
- All CRUD operations
|
- Cancel (gray) - closes modal
|
||||||
|
|
||||||
=== USAGE ===
|
=== WHAT CAN BE EDITED ===
|
||||||
|
|
||||||
Fresh start:
|
Everything:
|
||||||
rm mealprep.db
|
- ✅ Name
|
||||||
./start.sh
|
- ✅ Description
|
||||||
|
- ✅ Meal type (breakfast/lunch/snack)
|
||||||
|
- ✅ Prep time
|
||||||
|
- ✅ Image URL
|
||||||
|
- ✅ Instructions
|
||||||
|
|
||||||
1. Go to http://localhost:8080
|
=== AFTER SAVE ===
|
||||||
2. Redirected to /login
|
|
||||||
3. Click "Register here"
|
|
||||||
4. Create account
|
|
||||||
5. Automatically logged in
|
|
||||||
6. Use app normally
|
|
||||||
7. Data isolated to your account
|
|
||||||
8. Logout when done
|
|
||||||
|
|
||||||
=== SECURITY TESTED ===
|
- Modal closes automatically
|
||||||
|
- Meal card updates instantly
|
||||||
|
- No page reload (HTMX)
|
||||||
|
- All changes visible immediately
|
||||||
|
- Edit button still works
|
||||||
|
|
||||||
✅ SQL injection attempts blocked
|
=== CODE CHANGES ===
|
||||||
✅ XSS attacks prevented
|
|
||||||
✅ Session hijacking mitigated
|
|
||||||
✅ Password hashing verified
|
|
||||||
✅ User isolation confirmed
|
|
||||||
✅ Authentication bypass prevented
|
|
||||||
✅ Input validation working
|
|
||||||
|
|
||||||
=== PRODUCTION READY ===
|
handlers/meals.go:
|
||||||
|
- GetEditMealHandler() - shows modal with form
|
||||||
|
- UpdateMealHandler() - saves changes with security
|
||||||
|
- Added Edit button to meal cards
|
||||||
|
|
||||||
For production use:
|
main.go:
|
||||||
1. Set cookie Secure flag to true (requires HTTPS)
|
- /meals/:id/edit route (GET)
|
||||||
2. Add rate limiting on login/register
|
- /meals/:id/update route (POST)
|
||||||
3. Enable logging
|
|
||||||
4. Monitor failed attempts
|
|
||||||
5. Regular security audits
|
|
||||||
|
|
||||||
CURRENT STATUS:
|
static/styles.css:
|
||||||
✅ Safe for local use
|
- Modal overlay styles
|
||||||
✅ Safe for trusted networks
|
- Modal content styles
|
||||||
✅ All major vulnerabilities fixed
|
- Form styles
|
||||||
✅ Industry best practices followed
|
- Button styles
|
||||||
|
|
||||||
=== FINAL STATUS ===
|
=== SECURITY CHECKS ===
|
||||||
|
|
||||||
🟢 SAFE AND READY TO USE
|
Edit Modal:
|
||||||
|
1. Check session (middleware)
|
||||||
|
2. Get userID from context
|
||||||
|
3. Verify meal ownership
|
||||||
|
4. Show form if authorized
|
||||||
|
5. 404 if not found/unauthorized
|
||||||
|
|
||||||
The account system is:
|
Update:
|
||||||
- Fully functional
|
1. Check session (middleware)
|
||||||
- Thoroughly tested
|
2. Get userID from context
|
||||||
- Securely implemented
|
3. Verify meal ownership BEFORE update
|
||||||
- Production-ready (with HTTPS)
|
4. Validate all inputs
|
||||||
|
5. Validate meal type
|
||||||
|
6. UPDATE with user_id filter
|
||||||
|
7. Return 403 if unauthorized
|
||||||
|
|
||||||
No critical security issues found.
|
SQL Queries:
|
||||||
|
- All parameterized
|
||||||
|
- No string concatenation
|
||||||
|
- User isolation enforced
|
||||||
|
- No SQL injection possible
|
||||||
|
|
||||||
|
=== READY TO USE ===
|
||||||
|
|
||||||
|
✅ Build successful
|
||||||
|
✅ Security implemented
|
||||||
|
✅ User isolation working
|
||||||
|
✅ Modal working
|
||||||
|
✅ All features preserved
|
||||||
|
|
||||||
|
Just restart if needed!
|
||||||
|
|
||||||
|
|||||||
94
Makefile
94
Makefile
@@ -1,10 +1,13 @@
|
|||||||
.PHONY: run build clean test deps install
|
.PHONY: all run build clean test deps install docker-build docker-up docker-down docker-logs docker-restart docker-clean help
|
||||||
|
|
||||||
# Run the application
|
# Default target: build and start with Docker
|
||||||
|
all: docker-up
|
||||||
|
|
||||||
|
# Run the application locally
|
||||||
run:
|
run:
|
||||||
go run main.go
|
go run main.go
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary locally
|
||||||
build:
|
build:
|
||||||
go build -o mealprep main.go
|
go build -o mealprep main.go
|
||||||
|
|
||||||
@@ -33,14 +36,95 @@ test:
|
|||||||
install:
|
install:
|
||||||
go install
|
go install
|
||||||
|
|
||||||
|
# Docker: Build the Docker image
|
||||||
|
docker-build:
|
||||||
|
docker build -t mealprep:latest .
|
||||||
|
|
||||||
|
# Docker: Build and start containers
|
||||||
|
docker-up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Docker: Build and start with logs
|
||||||
|
docker-up-logs:
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Docker: Stop and remove containers
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Docker: View logs
|
||||||
|
docker-logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Docker: Restart containers
|
||||||
|
docker-restart:
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# Docker: Stop containers
|
||||||
|
docker-stop:
|
||||||
|
docker compose stop
|
||||||
|
|
||||||
|
# Docker: Start containers
|
||||||
|
docker-start:
|
||||||
|
docker compose start
|
||||||
|
|
||||||
|
# Docker: Clean everything (containers, volumes, images)
|
||||||
|
docker-clean:
|
||||||
|
docker compose down -v
|
||||||
|
docker rmi mealprep:latest 2>/dev/null || true
|
||||||
|
|
||||||
|
# Docker: Rebuild and restart
|
||||||
|
docker-rebuild:
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Docker: Shell into container
|
||||||
|
docker-shell:
|
||||||
|
docker exec -it mealprep-app /bin/sh
|
||||||
|
|
||||||
|
# Docker: Show container status
|
||||||
|
docker-status:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Deploy: Build and deploy to production
|
||||||
|
deploy: docker-build
|
||||||
|
@echo "Building and deploying to Docker server..."
|
||||||
|
docker compose up -d --build
|
||||||
|
@echo "Deployment complete!"
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " run - Run the application"
|
@echo ""
|
||||||
@echo " build - Build the binary"
|
@echo "Default:"
|
||||||
|
@echo " make / all - Build and start with Docker (default)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Local Development:"
|
||||||
|
@echo " run - Run the application locally"
|
||||||
|
@echo " build - Build the binary locally"
|
||||||
@echo " deps - Install dependencies"
|
@echo " deps - Install dependencies"
|
||||||
@echo " clean - Remove binary and database"
|
@echo " clean - Remove binary and database"
|
||||||
@echo " clean-db - Remove only database"
|
@echo " clean-db - Remove only database"
|
||||||
|
@echo " dev - Run with auto-reload (requires air)"
|
||||||
@echo " test - Run tests"
|
@echo " test - Run tests"
|
||||||
@echo " install - Install binary to GOPATH"
|
@echo " install - Install binary to GOPATH"
|
||||||
|
@echo ""
|
||||||
|
@echo "Docker Commands:"
|
||||||
|
@echo " docker-build - Build the Docker image"
|
||||||
|
@echo " docker-up - Build and start containers in background"
|
||||||
|
@echo " docker-up-logs - Build and start with logs attached"
|
||||||
|
@echo " docker-down - Stop and remove containers"
|
||||||
|
@echo " docker-logs - View container logs"
|
||||||
|
@echo " docker-restart - Restart containers"
|
||||||
|
@echo " docker-stop - Stop containers"
|
||||||
|
@echo " docker-start - Start stopped containers"
|
||||||
|
@echo " docker-clean - Remove everything (containers, volumes, images)"
|
||||||
|
@echo " docker-rebuild - Rebuild from scratch and start"
|
||||||
|
@echo " docker-shell - Open shell in container"
|
||||||
|
@echo " docker-status - Show container status"
|
||||||
|
@echo ""
|
||||||
|
@echo "Deployment:"
|
||||||
|
@echo " deploy - Build and deploy to production"
|
||||||
|
@echo ""
|
||||||
@echo " help - Show this help message"
|
@echo " help - Show this help message"
|
||||||
|
|||||||
314
database/db.go
314
database/db.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mealprep/models"
|
"mealprep/models"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
@@ -29,6 +30,11 @@ func InitDB(dbPath string) error {
|
|||||||
return fmt.Errorf("failed to create tables: %w", err)
|
return fmt.Errorf("failed to create tables: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err = runMigrations(); err != nil {
|
||||||
|
return fmt.Errorf("failed to run migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +72,26 @@ func createTables() error {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ingredients_user_id ON ingredients(user_id);
|
CREATE INDEX IF NOT EXISTS idx_ingredients_user_id ON ingredients(user_id);
|
||||||
|
|
||||||
|
-- Tags table (user-isolated)
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
|
||||||
|
|
||||||
|
-- Ingredient tags junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS ingredient_tags (
|
||||||
|
ingredient_id INTEGER NOT NULL,
|
||||||
|
tag_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (ingredient_id, tag_id),
|
||||||
|
FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Meals table (user-isolated)
|
-- Meals table (user-isolated)
|
||||||
CREATE TABLE IF NOT EXISTS meals (
|
CREATE TABLE IF NOT EXISTS meals (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -73,6 +99,9 @@ func createTables() error {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
meal_type TEXT NOT NULL DEFAULT 'lunch',
|
meal_type TEXT NOT NULL DEFAULT 'lunch',
|
||||||
|
instructions TEXT,
|
||||||
|
prep_time INTEGER DEFAULT 0,
|
||||||
|
image_url TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,6 +134,33 @@ func createTables() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runMigrations() error {
|
||||||
|
// Check if instructions column exists
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRow("SELECT COUNT(*) FROM pragma_table_info('meals') WHERE name='instructions'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new meal columns if they don't exist
|
||||||
|
if count == 0 {
|
||||||
|
_, err = DB.Exec("ALTER TABLE meals ADD COLUMN instructions TEXT")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = DB.Exec("ALTER TABLE meals ADD COLUMN prep_time INTEGER DEFAULT 0")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = DB.Exec("ALTER TABLE meals ADD COLUMN image_url TEXT")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// User operations
|
// User operations
|
||||||
|
|
||||||
func CreateUser(email, passwordHash string) (int64, error) {
|
func CreateUser(email, passwordHash string) (int64, error) {
|
||||||
@@ -213,14 +269,21 @@ func DeleteIngredient(userID, ingredientID int) error {
|
|||||||
"DELETE FROM ingredients WHERE id = ? AND user_id = ?",
|
"DELETE FROM ingredients WHERE id = ? AND user_id = ?",
|
||||||
ingredientID, userID,
|
ingredientID, userID,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up unused tags
|
||||||
|
CleanupUnusedTags(userID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Meal operations (user-isolated)
|
// Meal operations (user-isolated)
|
||||||
|
|
||||||
func GetAllMeals(userID int) ([]models.Meal, error) {
|
func GetAllMeals(userID int) ([]models.Meal, error) {
|
||||||
rows, err := DB.Query(
|
rows, err := DB.Query(
|
||||||
"SELECT id, user_id, name, description, meal_type FROM meals WHERE user_id = ? ORDER BY name",
|
"SELECT id, user_id, name, description, meal_type, instructions, prep_time, image_url FROM meals WHERE user_id = ? ORDER BY name",
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,7 +294,7 @@ func GetAllMeals(userID int) ([]models.Meal, error) {
|
|||||||
var meals []models.Meal
|
var meals []models.Meal
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var meal models.Meal
|
var meal models.Meal
|
||||||
if err := rows.Scan(&meal.ID, &meal.UserID, &meal.Name, &meal.Description, &meal.MealType); err != nil {
|
if err := rows.Scan(&meal.ID, &meal.UserID, &meal.Name, &meal.Description, &meal.MealType, &meal.Instructions, &meal.PrepTime, &meal.ImageURL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meals = append(meals, meal)
|
meals = append(meals, meal)
|
||||||
@@ -242,19 +305,19 @@ func GetAllMeals(userID int) ([]models.Meal, error) {
|
|||||||
func GetMealByID(userID, mealID int) (*models.Meal, error) {
|
func GetMealByID(userID, mealID int) (*models.Meal, error) {
|
||||||
var meal models.Meal
|
var meal models.Meal
|
||||||
err := DB.QueryRow(
|
err := DB.QueryRow(
|
||||||
"SELECT id, user_id, name, description, meal_type FROM meals WHERE id = ? AND user_id = ?",
|
"SELECT id, user_id, name, description, meal_type, instructions, prep_time, image_url FROM meals WHERE id = ? AND user_id = ?",
|
||||||
mealID, userID,
|
mealID, userID,
|
||||||
).Scan(&meal.ID, &meal.UserID, &meal.Name, &meal.Description, &meal.MealType)
|
).Scan(&meal.ID, &meal.UserID, &meal.Name, &meal.Description, &meal.MealType, &meal.Instructions, &meal.PrepTime, &meal.ImageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &meal, nil
|
return &meal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddMeal(userID int, name, description, mealType string) (int64, error) {
|
func AddMeal(userID int, name, description, mealType, instructions, imageURL string, prepTime int) (int64, error) {
|
||||||
result, err := DB.Exec(
|
result, err := DB.Exec(
|
||||||
"INSERT INTO meals (user_id, name, description, meal_type) VALUES (?, ?, ?, ?)",
|
"INSERT INTO meals (user_id, name, description, meal_type, instructions, prep_time, image_url) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
userID, name, description, mealType,
|
userID, name, description, mealType, instructions, prepTime, imageURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -419,3 +482,240 @@ func GetGroceryList(userID int) ([]models.GroceryItem, error) {
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag operations (user-isolated)
|
||||||
|
|
||||||
|
func GetAllTags(userID int) ([]models.Tag, error) {
|
||||||
|
rows, err := DB.Query(
|
||||||
|
"SELECT id, user_id, name FROM tags WHERE user_id = ? ORDER BY name",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tags []models.Tag
|
||||||
|
for rows.Next() {
|
||||||
|
var tag models.Tag
|
||||||
|
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUsedTags(userID int) ([]models.Tag, error) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT t.id, t.user_id, t.name
|
||||||
|
FROM tags t
|
||||||
|
JOIN ingredient_tags it ON t.id = it.tag_id
|
||||||
|
WHERE t.user_id = ?
|
||||||
|
ORDER BY t.name
|
||||||
|
`
|
||||||
|
rows, err := DB.Query(query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tags []models.Tag
|
||||||
|
for rows.Next() {
|
||||||
|
var tag models.Tag
|
||||||
|
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupUnusedTags(userID int) error {
|
||||||
|
// Delete tags that have no ingredient associations
|
||||||
|
_, err := DB.Exec(`
|
||||||
|
DELETE FROM tags
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT DISTINCT tag_id FROM ingredient_tags
|
||||||
|
)
|
||||||
|
`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddTag(userID int, name string) (int64, error) {
|
||||||
|
result, err := DB.Exec(
|
||||||
|
"INSERT INTO tags (user_id, name) VALUES (?, ?)",
|
||||||
|
userID, name,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrCreateTag(userID int, name string) (int64, error) {
|
||||||
|
// Try to get existing tag
|
||||||
|
var tagID int64
|
||||||
|
err := DB.QueryRow(
|
||||||
|
"SELECT id FROM tags WHERE user_id = ? AND name = ?",
|
||||||
|
userID, name,
|
||||||
|
).Scan(&tagID)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Tag doesn't exist, create it
|
||||||
|
return AddTag(userID, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tagID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteTag(userID, tagID int) error {
|
||||||
|
_, err := DB.Exec(
|
||||||
|
"DELETE FROM tags WHERE id = ? AND user_id = ?",
|
||||||
|
tagID, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingredient tag operations
|
||||||
|
|
||||||
|
func GetIngredientTags(userID, ingredientID int) ([]models.Tag, error) {
|
||||||
|
query := `
|
||||||
|
SELECT t.id, t.user_id, t.name
|
||||||
|
FROM tags t
|
||||||
|
JOIN ingredient_tags it ON t.id = it.tag_id
|
||||||
|
JOIN ingredients i ON it.ingredient_id = i.id
|
||||||
|
WHERE it.ingredient_id = ? AND i.user_id = ? AND t.user_id = ?
|
||||||
|
ORDER BY t.name
|
||||||
|
`
|
||||||
|
rows, err := DB.Query(query, ingredientID, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tags []models.Tag
|
||||||
|
for rows.Next() {
|
||||||
|
var tag models.Tag
|
||||||
|
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddIngredientTag(userID, ingredientID, tagID int) error {
|
||||||
|
// Verify ingredient and tag belong to user
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM ingredients i, tags t WHERE i.id = ? AND t.id = ? AND i.user_id = ? AND t.user_id = ?",
|
||||||
|
ingredientID, tagID, userID, userID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
return fmt.Errorf("ingredient or tag not found or doesn't belong to user")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = DB.Exec(
|
||||||
|
"INSERT OR IGNORE INTO ingredient_tags (ingredient_id, tag_id) VALUES (?, ?)",
|
||||||
|
ingredientID, tagID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveIngredientTag(userID, ingredientID, tagID int) error {
|
||||||
|
// Verify ownership
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM ingredients WHERE id = ? AND user_id = ?",
|
||||||
|
ingredientID, userID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
return fmt.Errorf("ingredient not found or doesn't belong to user")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = DB.Exec(
|
||||||
|
"DELETE FROM ingredient_tags WHERE ingredient_id = ? AND tag_id = ?",
|
||||||
|
ingredientID, tagID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up unused tags
|
||||||
|
CleanupUnusedTags(userID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetIngredientsWithTags(userID int) ([]models.Ingredient, error) {
|
||||||
|
rows, err := DB.Query(
|
||||||
|
"SELECT id, user_id, name, unit FROM ingredients WHERE user_id = ? ORDER BY name",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ingredients []models.Ingredient
|
||||||
|
for rows.Next() {
|
||||||
|
var ing models.Ingredient
|
||||||
|
if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tags for this ingredient
|
||||||
|
ing.Tags, _ = GetIngredientTags(userID, ing.ID)
|
||||||
|
ingredients = append(ingredients, ing)
|
||||||
|
}
|
||||||
|
return ingredients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchIngredientsByTags(userID int, tagNames []string) ([]models.Ingredient, error) {
|
||||||
|
if len(tagNames) == 0 {
|
||||||
|
return GetIngredientsWithTags(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query with placeholders for tag names
|
||||||
|
placeholders := make([]string, len(tagNames))
|
||||||
|
args := []interface{}{userID}
|
||||||
|
for i, name := range tagNames {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
args = append(args, len(tagNames))
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT DISTINCT i.id, i.user_id, i.name, i.unit
|
||||||
|
FROM ingredients i
|
||||||
|
JOIN ingredient_tags it ON i.id = it.ingredient_id
|
||||||
|
JOIN tags t ON it.tag_id = t.id
|
||||||
|
WHERE i.user_id = ? AND t.name IN (%s)
|
||||||
|
GROUP BY i.id
|
||||||
|
HAVING COUNT(DISTINCT t.id) = ?
|
||||||
|
ORDER BY i.name
|
||||||
|
`, strings.Join(placeholders, ","))
|
||||||
|
|
||||||
|
rows, err := DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ingredients []models.Ingredient
|
||||||
|
for rows.Next() {
|
||||||
|
var ing models.Ingredient
|
||||||
|
if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tags for this ingredient
|
||||||
|
ing.Tags, _ = GetIngredientTags(userID, ing.ID)
|
||||||
|
ingredients = append(ingredients, ing)
|
||||||
|
}
|
||||||
|
return ingredients, nil
|
||||||
|
}
|
||||||
|
|||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
mealprep:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mealprep-app
|
||||||
|
ports:
|
||||||
|
- "8090:8080"
|
||||||
|
volumes:
|
||||||
|
- mealprep-data:/app/data
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/app/data/mealprep.db
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"wget",
|
||||||
|
"--quiet",
|
||||||
|
"--tries=1",
|
||||||
|
"--spider",
|
||||||
|
"http://localhost:8080/health",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mealprep-data:
|
||||||
|
driver: local
|
||||||
@@ -12,7 +12,38 @@ import (
|
|||||||
// IngredientsHandler handles the ingredients page
|
// IngredientsHandler handles the ingredients page
|
||||||
func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r)
|
userID := auth.GetUserID(r)
|
||||||
ingredients, err := database.GetAllIngredients(userID)
|
|
||||||
|
// Check if there's a search query
|
||||||
|
searchTags := r.URL.Query().Get("tags")
|
||||||
|
var ingredients []interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if searchTags != "" {
|
||||||
|
tagNames := strings.Split(searchTags, ",")
|
||||||
|
for i := range tagNames {
|
||||||
|
tagNames[i] = strings.TrimSpace(tagNames[i])
|
||||||
|
}
|
||||||
|
ings, err := database.SearchIngredientsByTags(userID, tagNames)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ing := range ings {
|
||||||
|
ingredients = append(ingredients, ing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ings, err := database.GetIngredientsWithTags(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ing := range ings {
|
||||||
|
ingredients = append(ingredients, ing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all available tags for the filter UI (only tags that are in use)
|
||||||
|
allTags, err := database.GetUsedTags(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -25,14 +56,45 @@ func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
<form hx-post="/ingredients" hx-target="#ingredients-list" hx-swap="beforeend" class="add-form">
|
<form hx-post="/ingredients" hx-target="#ingredients-list" hx-swap="beforeend" class="add-form">
|
||||||
<input type="text" name="name" placeholder="Ingredient name" required />
|
<input type="text" name="name" placeholder="Ingredient name" required />
|
||||||
<input type="text" name="unit" placeholder="Unit (e.g., grams, cups)" required />
|
<input type="text" name="unit" placeholder="Unit (e.g., grams, cups)" required />
|
||||||
|
<input type="text" name="tags" placeholder="Tags (comma-separated)" />
|
||||||
<button type="submit">Add Ingredient</button>
|
<button type="submit">Add Ingredient</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<h3>Filter by Tags</h3>
|
||||||
|
<div class="tag-filter">
|
||||||
|
{{range .AllTags}}
|
||||||
|
<button class="tag-filter-btn"
|
||||||
|
onclick="toggleTagFilter('{{.Name}}')">
|
||||||
|
{{.Name}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div id="active-filters" class="active-filters"></div>
|
||||||
|
<button onclick="clearFilters()" class="clear-filters-btn">Clear Filters</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="ingredients-list" class="items-list">
|
<div id="ingredients-list" class="items-list">
|
||||||
{{range .}}
|
{{range .Ingredients}}
|
||||||
<div class="item" id="ingredient-{{.ID}}">
|
<div class="item" id="ingredient-{{.ID}}">
|
||||||
|
<div class="item-content">
|
||||||
<span class="item-name">{{.Name}}</span>
|
<span class="item-name">{{.Name}}</span>
|
||||||
<span class="item-unit">({{.Unit}})</span>
|
<span class="item-unit">({{.Unit}})</span>
|
||||||
|
<div class="item-tags">
|
||||||
|
{{range .Tags}}
|
||||||
|
<span class="tag">{{.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button
|
||||||
|
hx-get="/ingredients/{{.ID}}/tags"
|
||||||
|
hx-target="#tag-modal-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('tag-modal').style.display='block'"
|
||||||
|
class="edit-tags-btn">
|
||||||
|
Edit Tags
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-delete="/ingredients/{{.ID}}"
|
hx-delete="/ingredients/{{.ID}}"
|
||||||
hx-target="#ingredient-{{.ID}}"
|
hx-target="#ingredient-{{.ID}}"
|
||||||
@@ -42,13 +104,88 @@ func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag Edit Modal -->
|
||||||
|
<div id="tag-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" onclick="document.getElementById('tag-modal').style.display='none'">×</span>
|
||||||
|
<div id="tag-modal-content">
|
||||||
|
<!-- Content loaded via HTMX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let activeFilters = new Set();
|
||||||
|
|
||||||
|
// Listen for tag changes and refresh ingredients
|
||||||
|
document.body.addEventListener('refreshIngredients', function() {
|
||||||
|
// Close the modal first
|
||||||
|
document.getElementById('tag-modal').style.display='none';
|
||||||
|
// Then refresh the page
|
||||||
|
htmx.ajax('GET', '/ingredients', {target: '#ingredients-content', swap: 'innerHTML'});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleTagFilter(tagName) {
|
||||||
|
if (activeFilters.has(tagName)) {
|
||||||
|
activeFilters.delete(tagName);
|
||||||
|
} else {
|
||||||
|
activeFilters.add(tagName);
|
||||||
|
}
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
activeFilters.clear();
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilters() {
|
||||||
|
// Update active filters display
|
||||||
|
const activeFiltersDiv = document.getElementById('active-filters');
|
||||||
|
if (activeFilters.size > 0) {
|
||||||
|
activeFiltersDiv.innerHTML = 'Active filters: ' + Array.from(activeFilters).join(', ');
|
||||||
|
} else {
|
||||||
|
activeFiltersDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter button states
|
||||||
|
document.querySelectorAll('.tag-filter-btn').forEach(btn => {
|
||||||
|
if (activeFilters.has(btn.textContent.trim())) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload ingredients with filters
|
||||||
|
const url = activeFilters.size > 0
|
||||||
|
? '/ingredients?tags=' + Array.from(activeFilters).join(',')
|
||||||
|
: '/ingredients';
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {target: '#ingredients-content', swap: 'innerHTML'});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Ingredients []interface{}
|
||||||
|
AllTags []interface{}
|
||||||
|
}{
|
||||||
|
Ingredients: ingredients,
|
||||||
|
AllTags: []interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range allTags {
|
||||||
|
data.AllTags = append(data.AllTags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
t := template.Must(template.New("ingredients").Parse(tmpl))
|
t := template.Must(template.New("ingredients").Parse(tmpl))
|
||||||
t.Execute(w, ingredients)
|
t.Execute(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddIngredientHandler handles adding a new ingredient
|
// AddIngredientHandler handles adding a new ingredient
|
||||||
@@ -62,6 +199,7 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
unit := strings.TrimSpace(r.FormValue("unit"))
|
unit := strings.TrimSpace(r.FormValue("unit"))
|
||||||
|
tagsStr := strings.TrimSpace(r.FormValue("tags"))
|
||||||
|
|
||||||
if name == "" || unit == "" {
|
if name == "" || unit == "" {
|
||||||
http.Error(w, "Name and unit are required", http.StatusBadRequest)
|
http.Error(w, "Name and unit are required", http.StatusBadRequest)
|
||||||
@@ -74,10 +212,45 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process tags if provided
|
||||||
|
var tags []string
|
||||||
|
if tagsStr != "" {
|
||||||
|
tagNames := strings.Split(tagsStr, ",")
|
||||||
|
for _, tagName := range tagNames {
|
||||||
|
tagName = strings.TrimSpace(tagName)
|
||||||
|
if tagName != "" {
|
||||||
|
// Get or create tag
|
||||||
|
tagID, err := database.GetOrCreateTag(userID, tagName)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip this tag on error
|
||||||
|
}
|
||||||
|
// Link tag to ingredient
|
||||||
|
database.AddIngredientTag(userID, int(id), int(tagID))
|
||||||
|
tags = append(tags, tagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tmpl := `
|
tmpl := `
|
||||||
<div class="item" id="ingredient-{{.ID}}">
|
<div class="item" id="ingredient-{{.ID}}">
|
||||||
|
<div class="item-content">
|
||||||
<span class="item-name">{{.Name}}</span>
|
<span class="item-name">{{.Name}}</span>
|
||||||
<span class="item-unit">({{.Unit}})</span>
|
<span class="item-unit">({{.Unit}})</span>
|
||||||
|
<div class="item-tags">
|
||||||
|
{{range .Tags}}
|
||||||
|
<span class="tag">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button
|
||||||
|
hx-get="/ingredients/{{.ID}}/tags"
|
||||||
|
hx-target="#tag-modal-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('tag-modal').style.display='block'"
|
||||||
|
class="edit-tags-btn">
|
||||||
|
Edit Tags
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-delete="/ingredients/{{.ID}}"
|
hx-delete="/ingredients/{{.ID}}"
|
||||||
hx-target="#ingredient-{{.ID}}"
|
hx-target="#ingredient-{{.ID}}"
|
||||||
@@ -87,13 +260,15 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Unit string
|
Unit string
|
||||||
}{id, name, unit}
|
Tags []string
|
||||||
|
}{id, name, unit, tags}
|
||||||
|
|
||||||
t := template.Must(template.New("ingredient").Parse(tmpl))
|
t := template.Must(template.New("ingredient").Parse(tmpl))
|
||||||
t.Execute(w, data)
|
t.Execute(w, data)
|
||||||
@@ -117,3 +292,224 @@ func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIngredientTagsHandler returns the tag editing UI for an ingredient
|
||||||
|
func GetIngredientTagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
// Extract ingredient ID from path /ingredients/{id}/tags
|
||||||
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||||
|
if len(pathParts) < 1 {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current tags for ingredient
|
||||||
|
currentTags, err := database.GetIngredientTags(userID, ingredientID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all available tags
|
||||||
|
allTags, err := database.GetAllTags(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a set of current tag IDs for easy lookup
|
||||||
|
currentTagIDs := make(map[int]bool)
|
||||||
|
for _, tag := range currentTags {
|
||||||
|
currentTagIDs[tag.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := `
|
||||||
|
<h3>Edit Tags</h3>
|
||||||
|
<div class="tag-edit-section">
|
||||||
|
<h4>Current Tags</h4>
|
||||||
|
<div id="current-tags-{{.IngredientID}}">
|
||||||
|
{{range .CurrentTags}}
|
||||||
|
<span class="tag">
|
||||||
|
{{.Name}}
|
||||||
|
<button
|
||||||
|
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
|
||||||
|
hx-target="#current-tags-{{$.IngredientID}}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="tag-remove">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Add Tags</h4>
|
||||||
|
<form hx-post="/ingredients/{{.IngredientID}}/tags"
|
||||||
|
hx-target="#current-tags-{{.IngredientID}}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="add-tag-form">
|
||||||
|
<input type="text" name="tag_name" placeholder="New tag name" list="existing-tags" required />
|
||||||
|
<datalist id="existing-tags">
|
||||||
|
{{range .AllTags}}
|
||||||
|
{{if not (index $.CurrentTagIDs .ID)}}
|
||||||
|
<option value="{{.Name}}">
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</datalist>
|
||||||
|
<button type="submit">Add Tag</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
IngredientID int
|
||||||
|
CurrentTags []interface{}
|
||||||
|
AllTags []interface{}
|
||||||
|
CurrentTagIDs map[int]bool
|
||||||
|
}{
|
||||||
|
IngredientID: ingredientID,
|
||||||
|
CurrentTags: []interface{}{},
|
||||||
|
AllTags: []interface{}{},
|
||||||
|
CurrentTagIDs: currentTagIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range currentTags {
|
||||||
|
data.CurrentTags = append(data.CurrentTags, tag)
|
||||||
|
}
|
||||||
|
for _, tag := range allTags {
|
||||||
|
data.AllTags = append(data.AllTags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := template.Must(template.New("ingredient-tags").Parse(tmpl))
|
||||||
|
t.Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIngredientTagHandler adds a tag to an ingredient
|
||||||
|
func AddIngredientTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
// Extract ingredient ID from path
|
||||||
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||||
|
if len(pathParts) < 1 {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagName := strings.TrimSpace(r.FormValue("tag_name"))
|
||||||
|
if tagName == "" {
|
||||||
|
http.Error(w, "Tag name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create the tag
|
||||||
|
tagID, err := database.GetOrCreateTag(userID, tagName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag to ingredient
|
||||||
|
if err := database.AddIngredientTag(userID, ingredientID, int(tagID)); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated tag list with trigger to refresh page
|
||||||
|
w.Header().Set("HX-Trigger", "refreshIngredients")
|
||||||
|
renderCurrentTags(w, userID, ingredientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIngredientTagHandler removes a tag from an ingredient
|
||||||
|
func RemoveIngredientTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
// Extract ingredient ID and tag ID from path /ingredients/{ingredient_id}/tags/{tag_id}
|
||||||
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||||
|
if len(pathParts) < 3 {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagID, err := strconv.Atoi(pathParts[2])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid tag ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tag from ingredient
|
||||||
|
if err := database.RemoveIngredientTag(userID, ingredientID, tagID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated tag list with trigger to refresh page
|
||||||
|
w.Header().Set("HX-Trigger", "refreshIngredients")
|
||||||
|
renderCurrentTags(w, userID, ingredientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render current tags
|
||||||
|
func renderCurrentTags(w http.ResponseWriter, userID, ingredientID int) {
|
||||||
|
currentTags, err := database.GetIngredientTags(userID, ingredientID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := `
|
||||||
|
{{range .Tags}}
|
||||||
|
<span class="tag">
|
||||||
|
{{.Name}}
|
||||||
|
<button
|
||||||
|
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
|
||||||
|
hx-target="#current-tags-{{$.IngredientID}}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="tag-remove">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
type TagWithID struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Tags []TagWithID
|
||||||
|
IngredientID int
|
||||||
|
}{
|
||||||
|
Tags: []TagWithID{},
|
||||||
|
IngredientID: ingredientID,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range currentTags {
|
||||||
|
data.Tags = append(data.Tags, TagWithID{ID: tag.ID, Name: tag.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
t := template.Must(template.New("current-tags").Parse(tmpl))
|
||||||
|
t.Execute(w, data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func MealsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
<option value="lunch">Lunch</option>
|
<option value="lunch">Lunch</option>
|
||||||
<option value="snack">Snack</option>
|
<option value="snack">Snack</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input type="number" name="prep_time" placeholder="Prep time (minutes)" min="0" />
|
||||||
|
<input type="url" name="image_url" placeholder="Image URL (optional)" />
|
||||||
|
<textarea name="instructions" placeholder="Instructions (optional)" rows="3"></textarea>
|
||||||
<button type="submit">Add Meal</button>
|
<button type="submit">Add Meal</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -38,12 +41,33 @@ func MealsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
{{range .}}
|
{{range .}}
|
||||||
<div class="meal-item" id="meal-{{.ID}}">
|
<div class="meal-item" id="meal-{{.ID}}">
|
||||||
<div class="meal-header">
|
<div class="meal-header">
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<img src="{{.ImageURL}}" alt="{{.Name}}" class="meal-image" />
|
||||||
|
{{end}}
|
||||||
|
<div class="meal-info-section">
|
||||||
<div>
|
<div>
|
||||||
<span class="item-name">{{.Name}}</span>
|
<span class="item-name">{{.Name}}</span>
|
||||||
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
||||||
|
{{if gt .PrepTime 0}}
|
||||||
|
<span class="prep-time">⏱️ {{.PrepTime}} min</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<span class="item-description">{{.Description}}</span>
|
<span class="item-description">{{.Description}}</span>
|
||||||
|
{{if .Instructions}}
|
||||||
|
<details class="instructions-preview">
|
||||||
|
<summary>Instructions</summary>
|
||||||
|
<p class="instructions-text">{{.Instructions}}</p>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<button
|
||||||
|
hx-get="/meals/{{.ID}}/edit"
|
||||||
|
hx-target="#edit-modal"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="edit-btn">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-get="/meals/{{.ID}}/ingredients"
|
hx-get="/meals/{{.ID}}/ingredients"
|
||||||
hx-target="#meal-{{.ID}}-ingredients"
|
hx-target="#meal-{{.ID}}-ingredients"
|
||||||
@@ -65,6 +89,9 @@ func MealsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div id="edit-modal"></div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -84,6 +111,13 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
description := strings.TrimSpace(r.FormValue("description"))
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
mealType := r.FormValue("meal_type")
|
mealType := r.FormValue("meal_type")
|
||||||
|
instructions := strings.TrimSpace(r.FormValue("instructions"))
|
||||||
|
imageURL := strings.TrimSpace(r.FormValue("image_url"))
|
||||||
|
|
||||||
|
prepTime := 0
|
||||||
|
if prepTimeStr := r.FormValue("prep_time"); prepTimeStr != "" {
|
||||||
|
prepTime, _ = strconv.Atoi(prepTimeStr)
|
||||||
|
}
|
||||||
|
|
||||||
if name == "" || mealType == "" {
|
if name == "" || mealType == "" {
|
||||||
http.Error(w, "Name and meal type are required", http.StatusBadRequest)
|
http.Error(w, "Name and meal type are required", http.StatusBadRequest)
|
||||||
@@ -96,7 +130,7 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := database.AddMeal(userID, name, description, mealType)
|
id, err := database.AddMeal(userID, name, description, mealType, instructions, imageURL, prepTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -105,12 +139,33 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
tmpl := `
|
tmpl := `
|
||||||
<div class="meal-item" id="meal-{{.ID}}">
|
<div class="meal-item" id="meal-{{.ID}}">
|
||||||
<div class="meal-header">
|
<div class="meal-header">
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<img src="{{.ImageURL}}" alt="{{.Name}}" class="meal-image" />
|
||||||
|
{{end}}
|
||||||
|
<div class="meal-info-section">
|
||||||
<div>
|
<div>
|
||||||
<span class="item-name">{{.Name}}</span>
|
<span class="item-name">{{.Name}}</span>
|
||||||
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
||||||
|
{{if gt .PrepTime 0}}
|
||||||
|
<span class="prep-time">⏱️ {{.PrepTime}} min</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<span class="item-description">{{.Description}}</span>
|
<span class="item-description">{{.Description}}</span>
|
||||||
|
{{if .Instructions}}
|
||||||
|
<details class="instructions-preview">
|
||||||
|
<summary>Instructions</summary>
|
||||||
|
<p class="instructions-text">{{.Instructions}}</p>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<button
|
||||||
|
hx-get="/meals/{{.ID}}/edit"
|
||||||
|
hx-target="#edit-modal"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="edit-btn">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-get="/meals/{{.ID}}/ingredients"
|
hx-get="/meals/{{.ID}}/ingredients"
|
||||||
hx-target="#meal-{{.ID}}-ingredients"
|
hx-target="#meal-{{.ID}}-ingredients"
|
||||||
@@ -137,7 +192,10 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
MealType string
|
MealType string
|
||||||
}{id, name, description, mealType}
|
Instructions string
|
||||||
|
PrepTime int
|
||||||
|
ImageURL string
|
||||||
|
}{id, name, description, mealType, instructions, prepTime, imageURL}
|
||||||
|
|
||||||
t := template.Must(template.New("meal").Parse(tmpl))
|
t := template.Must(template.New("meal").Parse(tmpl))
|
||||||
t.Execute(w, data)
|
t.Execute(w, data)
|
||||||
@@ -345,3 +403,214 @@ func DeleteMealIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEditMealHandler shows the edit modal for a meal
|
||||||
|
func GetEditMealHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mealID, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid meal ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get meal with security check
|
||||||
|
meal, err := database.GetMealByID(userID, mealID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Meal not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := `
|
||||||
|
<div class="modal-overlay" id="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Edit Meal</h3>
|
||||||
|
<form hx-post="/meals/{{.ID}}/update"
|
||||||
|
hx-target="#meal-{{.ID}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" name="name" value="{{.Name}}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description:</label>
|
||||||
|
<input type="text" name="description" value="{{.Description}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type:</label>
|
||||||
|
<select name="meal_type" required>
|
||||||
|
<option value="breakfast" {{if eq .MealType "breakfast"}}selected{{end}}>Breakfast</option>
|
||||||
|
<option value="lunch" {{if eq .MealType "lunch"}}selected{{end}}>Lunch</option>
|
||||||
|
<option value="snack" {{if eq .MealType "snack"}}selected{{end}}>Snack</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prep Time (minutes):</label>
|
||||||
|
<input type="number" name="prep_time" value="{{.PrepTime}}" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Image URL:</label>
|
||||||
|
<input type="url" name="image_url" value="{{.ImageURL}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Instructions:</label>
|
||||||
|
<textarea name="instructions" rows="5">{{.Instructions}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" onclick="closeModal()" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('edit-modal').innerHTML = '';
|
||||||
|
}
|
||||||
|
document.getElementById('modal-overlay').addEventListener('click', function(e) {
|
||||||
|
if (e.target.id === 'modal-overlay') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
|
||||||
|
t := template.Must(template.New("editMeal").Parse(tmpl))
|
||||||
|
t.Execute(w, meal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMealHandler updates a meal
|
||||||
|
func UpdateMealHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mealID, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid meal ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
_, err = database.GetMealByID(userID, mealID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Meal not found or access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
|
mealType := r.FormValue("meal_type")
|
||||||
|
instructions := strings.TrimSpace(r.FormValue("instructions"))
|
||||||
|
imageURL := strings.TrimSpace(r.FormValue("image_url"))
|
||||||
|
|
||||||
|
prepTime := 0
|
||||||
|
if prepTimeStr := r.FormValue("prep_time"); prepTimeStr != "" {
|
||||||
|
prepTime, _ = strconv.Atoi(prepTimeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" || mealType == "" {
|
||||||
|
http.Error(w, "Name and meal type are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate meal type
|
||||||
|
if mealType != "breakfast" && mealType != "lunch" && mealType != "snack" {
|
||||||
|
http.Error(w, "Invalid meal type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meal
|
||||||
|
_, err = database.DB.Exec(
|
||||||
|
"UPDATE meals SET name = ?, description = ?, meal_type = ?, instructions = ?, prep_time = ?, image_url = ? WHERE id = ? AND user_id = ?",
|
||||||
|
name, description, mealType, instructions, prepTime, imageURL, mealID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated meal card
|
||||||
|
tmpl := `
|
||||||
|
<div class="meal-item" id="meal-{{.ID}}">
|
||||||
|
<div class="meal-header">
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<img src="{{.ImageURL}}" alt="{{.Name}}" class="meal-image" />
|
||||||
|
{{end}}
|
||||||
|
<div class="meal-info-section">
|
||||||
|
<div>
|
||||||
|
<span class="item-name">{{.Name}}</span>
|
||||||
|
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
||||||
|
{{if gt .PrepTime 0}}
|
||||||
|
<span class="prep-time">⏱️ {{.PrepTime}} min</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="item-description">{{.Description}}</span>
|
||||||
|
{{if .Instructions}}
|
||||||
|
<details class="instructions-preview">
|
||||||
|
<summary>Instructions</summary>
|
||||||
|
<p class="instructions-text">{{.Instructions}}</p>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-get="/meals/{{.ID}}/edit"
|
||||||
|
hx-target="#edit-modal"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="edit-btn">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-get="/meals/{{.ID}}/ingredients"
|
||||||
|
hx-target="#meal-{{.ID}}-ingredients"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="view-btn">
|
||||||
|
View Ingredients
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-delete="/meals/{{.ID}}"
|
||||||
|
hx-target="#meal-{{.ID}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to delete this meal?"
|
||||||
|
class="delete-btn">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="meal-{{.ID}}-ingredients" class="meal-ingredients"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('edit-modal').innerHTML = '';
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
MealType string
|
||||||
|
Instructions string
|
||||||
|
PrepTime int
|
||||||
|
ImageURL string
|
||||||
|
}{mealID, name, description, mealType, instructions, prepTime, imageURL}
|
||||||
|
|
||||||
|
t := template.Must(template.New("updatedMeal").Parse(tmpl))
|
||||||
|
t.Execute(w, data)
|
||||||
|
}
|
||||||
|
|||||||
44
main.go
44
main.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"mealprep/database"
|
"mealprep/database"
|
||||||
"mealprep/handlers"
|
"mealprep/handlers"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,8 +16,14 @@ func main() {
|
|||||||
// Print startup banner
|
// Print startup banner
|
||||||
printBanner()
|
printBanner()
|
||||||
|
|
||||||
|
// Get database path from environment variable or use default
|
||||||
|
dbPath := os.Getenv("DB_PATH")
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "mealprep.db"
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
if err := database.InitDB("mealprep.db"); err != nil {
|
if err := database.InitDB(dbPath); err != nil {
|
||||||
log.Fatalf("Failed to initialize database: %v", err)
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
}
|
}
|
||||||
defer database.DB.Close()
|
defer database.DB.Close()
|
||||||
@@ -48,6 +55,12 @@ func main() {
|
|||||||
})
|
})
|
||||||
http.HandleFunc("/logout", handlers.LogoutHandler)
|
http.HandleFunc("/logout", handlers.LogoutHandler)
|
||||||
|
|
||||||
|
// Health check endpoint (public, for Docker)
|
||||||
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
http.HandleFunc("/", auth.RequireAuth(indexHandler))
|
http.HandleFunc("/", auth.RequireAuth(indexHandler))
|
||||||
|
|
||||||
@@ -63,11 +76,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if strings.Contains(path, "/tags") {
|
||||||
|
// Ingredient tag routes
|
||||||
|
if r.Method == "GET" {
|
||||||
|
handlers.GetIngredientTagsHandler(w, r)
|
||||||
|
} else if r.Method == "POST" {
|
||||||
|
handlers.AddIngredientTagHandler(w, r)
|
||||||
|
} else if r.Method == "DELETE" {
|
||||||
|
handlers.RemoveIngredientTagHandler(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ingredient delete route
|
||||||
if r.Method == "DELETE" {
|
if r.Method == "DELETE" {
|
||||||
handlers.DeleteIngredientHandler(w, r)
|
handlers.DeleteIngredientHandler(w, r)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Meals (protected)
|
// Meals (protected)
|
||||||
@@ -94,6 +122,20 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
|
} else if strings.Contains(path, "/edit") {
|
||||||
|
// Meal edit route
|
||||||
|
if r.Method == "GET" {
|
||||||
|
handlers.GetEditMealHandler(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
} else if strings.Contains(path, "/update") {
|
||||||
|
// Meal update route
|
||||||
|
if r.Method == "POST" {
|
||||||
|
handlers.UpdateMealHandler(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Meal delete route
|
// Meal delete route
|
||||||
if r.Method == "DELETE" {
|
if r.Method == "DELETE" {
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ type Ingredient struct {
|
|||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp"
|
Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp"
|
||||||
|
Tags []Tag `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag represents a tag for categorizing ingredients
|
||||||
|
type Tag struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngredientTag represents the many-to-many relationship between ingredients and tags
|
||||||
|
type IngredientTag struct {
|
||||||
|
IngredientID int `json:"ingredient_id"`
|
||||||
|
TagID int `json:"tag_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meal represents a meal recipe
|
// Meal represents a meal recipe
|
||||||
@@ -33,6 +47,9 @@ type Meal struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
MealType string `json:"meal_type"` // "breakfast", "lunch", "snack"
|
MealType string `json:"meal_type"` // "breakfast", "lunch", "snack"
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
PrepTime int `json:"prep_time"` // in minutes
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MealIngredient represents an ingredient in a meal with its quantity
|
// MealIngredient represents an ingredient in a meal with its quantity
|
||||||
|
|||||||
@@ -100,6 +100,23 @@ h4 {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
@@ -202,14 +219,70 @@ button:hover {
|
|||||||
.meal-header {
|
.meal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
gap: 10px;
|
gap: 15px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meal-header > div {
|
.meal-image {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-info-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-info-section > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-time {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-preview {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-preview summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #3498db;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-preview summary:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-left: 3px solid #3498db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #2c3e50;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-header > div:last-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -387,6 +460,201 @@ button:hover {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags-btn {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
padding: 8px 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags-btn:hover {
|
||||||
|
background-color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag Filter Section */
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
color: #2c3e50;
|
||||||
|
border: 2px solid #bdc3c7;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter-btn:hover {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
border-color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter-btn.active {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filters {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
min-height: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filters-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filters-btn:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag Edit Modal */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: #aaa;
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover,
|
||||||
|
.close:focus {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-edit-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-edit-section h4 {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current-tags-{{.IngredientID}},
|
||||||
|
[id^="current-tags-"] {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove {
|
||||||
|
background-color: transparent;
|
||||||
|
color: white;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-form input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-form button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Authentication Pages */
|
/* Authentication Pages */
|
||||||
.auth-container {
|
.auth-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -514,6 +782,125 @@ button:hover {
|
|||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form .form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form .form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form .form-group input,
|
||||||
|
.edit-form .form-group select,
|
||||||
|
.edit-form .form-group textarea {
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form .form-group input:focus,
|
||||||
|
.edit-form .form-group select:focus,
|
||||||
|
.edit-form .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form .form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
background-color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
Reference in New Issue
Block a user