base feature
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
mealprep
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
67
IMPLEMENTATION_NOTES.txt
Normal file
67
IMPLEMENTATION_NOTES.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
MEAL TYPES - WORKING!
|
||||
|
||||
=== ✅ IMPLEMENTATION COMPLETE ===
|
||||
|
||||
The meal types feature is fully working.
|
||||
|
||||
If you had an old database, you need to either:
|
||||
1. Delete mealprep.db and restart (fresh start)
|
||||
2. Or the migration will auto-add the meal_type column
|
||||
|
||||
=== WHAT'S WORKING ===
|
||||
|
||||
✅ Meals tab loads with type dropdown
|
||||
✅ Week plan loads with 3 sections per day
|
||||
✅ Each section filters meals by type
|
||||
✅ Grocery list still works
|
||||
✅ All CRUD operations working
|
||||
|
||||
=== FRESH START (RECOMMENDED) ===
|
||||
|
||||
If meals/week plan tabs don't show:
|
||||
|
||||
rm mealprep.db
|
||||
./start.sh
|
||||
|
||||
This creates a fresh database with meal_type column.
|
||||
|
||||
=== MIGRATION INCLUDED ===
|
||||
|
||||
The code now includes automatic migration:
|
||||
- Checks if meal_type column exists
|
||||
- Adds it if missing
|
||||
- Sets default to 'lunch' for existing meals
|
||||
|
||||
=== FEATURES ===
|
||||
|
||||
1. CREATE MEAL
|
||||
- Name, description, type dropdown
|
||||
- Tags: 🟠 Breakfast, 🔵 Lunch, 🟣 Snack
|
||||
|
||||
2. WEEK PLAN (per day)
|
||||
- 🌅 Breakfast section
|
||||
- 🍽️ Lunch section
|
||||
- 🍪 Snack section
|
||||
- Each with filtered dropdown
|
||||
|
||||
3. GROCERY LIST
|
||||
- Aggregates all meals regardless of type
|
||||
- Works perfectly
|
||||
|
||||
=== TESTED ===
|
||||
|
||||
✅ Server starts successfully
|
||||
✅ /meals endpoint returns HTML
|
||||
✅ /week-plan endpoint returns HTML
|
||||
✅ Type dropdowns render
|
||||
✅ Sections organized by meal type
|
||||
|
||||
=== READY TO USE ===
|
||||
|
||||
Fresh database:
|
||||
rm mealprep.db
|
||||
./start.sh
|
||||
http://localhost:8080
|
||||
|
||||
Everything works!
|
||||
|
||||
46
Makefile
Normal file
46
Makefile
Normal file
@@ -0,0 +1,46 @@
|
||||
.PHONY: run build clean test deps install
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
go run main.go
|
||||
|
||||
# Build the binary
|
||||
build:
|
||||
go build -o mealprep main.go
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
go mod tidy
|
||||
go mod download
|
||||
|
||||
# Clean build artifacts and database
|
||||
clean:
|
||||
rm -f mealprep mealprep.db
|
||||
|
||||
# Clean only database (for fresh start)
|
||||
clean-db:
|
||||
rm -f mealprep.db
|
||||
|
||||
# Run with auto-reload (requires air: go install github.com/cosmtrek/air@latest)
|
||||
dev:
|
||||
air
|
||||
|
||||
# Test the application
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Install the binary to GOPATH
|
||||
install:
|
||||
go install
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " run - Run the application"
|
||||
@echo " build - Build the binary"
|
||||
@echo " deps - Install dependencies"
|
||||
@echo " clean - Remove binary and database"
|
||||
@echo " clean-db - Remove only database"
|
||||
@echo " test - Run tests"
|
||||
@echo " install - Install binary to GOPATH"
|
||||
@echo " help - Show this help message"
|
||||
276
database/db.go
Normal file
276
database/db.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mealprep/models"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// InitDB initializes the database and creates tables
|
||||
func InitDB(dbPath string) error {
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
if err = DB.Ping(); err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
// Create tables
|
||||
if err = createTables(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func createTables() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS ingredients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
unit TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
meal_type TEXT NOT NULL DEFAULT 'lunch'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meal_ingredients (
|
||||
meal_id INTEGER NOT NULL,
|
||||
ingredient_id INTEGER NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
PRIMARY KEY (meal_id, ingredient_id),
|
||||
FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS week_plan (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
meal_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
|
||||
_, err := DB.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
// Check if meal_type column exists
|
||||
var count int
|
||||
err := DB.QueryRow("SELECT COUNT(*) FROM pragma_table_info('meals') WHERE name='meal_type'").Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add meal_type column if it doesn't exist
|
||||
if count == 0 {
|
||||
_, err = DB.Exec("ALTER TABLE meals ADD COLUMN meal_type TEXT NOT NULL DEFAULT 'lunch'")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ingredient operations
|
||||
|
||||
func GetAllIngredients() ([]models.Ingredient, error) {
|
||||
rows, err := DB.Query("SELECT id, name, unit FROM ingredients ORDER BY name")
|
||||
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.Name, &ing.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingredients = append(ingredients, ing)
|
||||
}
|
||||
return ingredients, nil
|
||||
}
|
||||
|
||||
func AddIngredient(name, unit string) (int64, error) {
|
||||
result, err := DB.Exec("INSERT INTO ingredients (name, unit) VALUES (?, ?)", name, unit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func DeleteIngredient(id int) error {
|
||||
_, err := DB.Exec("DELETE FROM ingredients WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Meal operations
|
||||
|
||||
func GetAllMeals() ([]models.Meal, error) {
|
||||
rows, err := DB.Query("SELECT id, name, description, meal_type FROM meals ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var meals []models.Meal
|
||||
for rows.Next() {
|
||||
var meal models.Meal
|
||||
if err := rows.Scan(&meal.ID, &meal.Name, &meal.Description, &meal.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meals = append(meals, meal)
|
||||
}
|
||||
return meals, nil
|
||||
}
|
||||
|
||||
func GetMealByID(id int) (*models.Meal, error) {
|
||||
var meal models.Meal
|
||||
err := DB.QueryRow("SELECT id, name, description, meal_type FROM meals WHERE id = ?", id).
|
||||
Scan(&meal.ID, &meal.Name, &meal.Description, &meal.MealType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meal, nil
|
||||
}
|
||||
|
||||
func AddMeal(name, description, mealType string) (int64, error) {
|
||||
result, err := DB.Exec("INSERT INTO meals (name, description, meal_type) VALUES (?, ?, ?)", name, description, mealType)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func DeleteMeal(id int) error {
|
||||
_, err := DB.Exec("DELETE FROM meals WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Meal Ingredients operations
|
||||
|
||||
func GetMealIngredients(mealID int) ([]models.MealIngredient, error) {
|
||||
query := `
|
||||
SELECT mi.meal_id, mi.ingredient_id, mi.quantity, i.name, i.unit
|
||||
FROM meal_ingredients mi
|
||||
JOIN ingredients i ON mi.ingredient_id = i.id
|
||||
WHERE mi.meal_id = ?
|
||||
ORDER BY i.name
|
||||
`
|
||||
rows, err := DB.Query(query, mealID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ingredients []models.MealIngredient
|
||||
for rows.Next() {
|
||||
var mi models.MealIngredient
|
||||
if err := rows.Scan(&mi.MealID, &mi.IngredientID, &mi.Quantity, &mi.IngredientName, &mi.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingredients = append(ingredients, mi)
|
||||
}
|
||||
return ingredients, nil
|
||||
}
|
||||
|
||||
func AddMealIngredient(mealID, ingredientID int, quantity float64) error {
|
||||
_, err := DB.Exec(
|
||||
"INSERT OR REPLACE INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES (?, ?, ?)",
|
||||
mealID, ingredientID, quantity,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteMealIngredient(mealID, ingredientID int) error {
|
||||
_, err := DB.Exec("DELETE FROM meal_ingredients WHERE meal_id = ? AND ingredient_id = ?", mealID, ingredientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Week Plan operations
|
||||
|
||||
func GetWeekPlan() ([]models.WeekPlanEntry, error) {
|
||||
query := `
|
||||
SELECT wp.id, wp.date, wp.meal_id, m.name, m.meal_type
|
||||
FROM week_plan wp
|
||||
JOIN meals m ON wp.meal_id = m.id
|
||||
ORDER BY wp.date, m.meal_type
|
||||
`
|
||||
rows, err := DB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []models.WeekPlanEntry
|
||||
for rows.Next() {
|
||||
var entry models.WeekPlanEntry
|
||||
var dateStr string
|
||||
if err := rows.Scan(&entry.ID, &dateStr, &entry.MealID, &entry.MealName, &entry.MealType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.Date, _ = time.Parse("2006-01-02", dateStr)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func AddWeekPlanEntry(date time.Time, mealID int) error {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
_, err := DB.Exec("INSERT INTO week_plan (date, meal_id) VALUES (?, ?)", dateStr, mealID)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteWeekPlanEntry(id int) error {
|
||||
_, err := DB.Exec("DELETE FROM week_plan WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Grocery List operations
|
||||
|
||||
func GetGroceryList() ([]models.GroceryItem, error) {
|
||||
query := `
|
||||
SELECT i.name, SUM(mi.quantity) as total_quantity, i.unit
|
||||
FROM week_plan wp
|
||||
JOIN meal_ingredients mi ON wp.meal_id = mi.meal_id
|
||||
JOIN ingredients i ON mi.ingredient_id = i.id
|
||||
GROUP BY i.id, i.name, i.unit
|
||||
ORDER BY i.name
|
||||
`
|
||||
rows, err := DB.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []models.GroceryItem
|
||||
for rows.Next() {
|
||||
var item models.GroceryItem
|
||||
if err := rows.Scan(&item.IngredientName, &item.TotalQuantity, &item.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module mealprep
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.18
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
45
handlers/grocerylist.go
Normal file
45
handlers/grocerylist.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"mealprep/database"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GroceryListHandler handles the grocery list page
|
||||
func GroceryListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
groceryItems, err := database.GetGroceryList()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div id="grocery-content">
|
||||
<h2>Grocery List</h2>
|
||||
<p class="info">This list is automatically generated from your week plan.</p>
|
||||
|
||||
{{if .Items}}
|
||||
<div class="grocery-list">
|
||||
{{range .Items}}
|
||||
<div class="grocery-item">
|
||||
<span class="grocery-name">{{.IngredientName}}</span>
|
||||
<span class="grocery-quantity">{{printf "%.2f" .TotalQuantity}} {{.Unit}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-state">No items in your grocery list. Add meals to your week plan to generate a grocery list.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
Items interface{}
|
||||
}{
|
||||
Items: groceryItems,
|
||||
}
|
||||
|
||||
t := template.Must(template.New("grocerylist").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
113
handlers/ingredients.go
Normal file
113
handlers/ingredients.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"mealprep/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IngredientsHandler handles the ingredients page
|
||||
func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ingredients, err := database.GetAllIngredients()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div id="ingredients-content">
|
||||
<h2>Ingredients</h2>
|
||||
|
||||
<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="unit" placeholder="Unit (e.g., grams, cups)" required />
|
||||
<button type="submit">Add Ingredient</button>
|
||||
</form>
|
||||
|
||||
<div id="ingredients-list" class="items-list">
|
||||
{{range .}}
|
||||
<div class="item" id="ingredient-{{.ID}}">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<button
|
||||
hx-delete="/ingredients/{{.ID}}"
|
||||
hx-target="#ingredient-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this ingredient?"
|
||||
class="delete-btn">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
t := template.Must(template.New("ingredients").Parse(tmpl))
|
||||
t.Execute(w, ingredients)
|
||||
}
|
||||
|
||||
// AddIngredientHandler handles adding a new ingredient
|
||||
func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
unit := strings.TrimSpace(r.FormValue("unit"))
|
||||
|
||||
if name == "" || unit == "" {
|
||||
http.Error(w, "Name and unit are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := database.AddIngredient(name, unit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div class="item" id="ingredient-{{.ID}}">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<button
|
||||
hx-delete="/ingredients/{{.ID}}"
|
||||
hx-target="#ingredient-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this ingredient?"
|
||||
class="delete-btn">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
ID int64
|
||||
Name string
|
||||
Unit string
|
||||
}{id, name, unit}
|
||||
|
||||
t := template.Must(template.New("ingredient").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// DeleteIngredientHandler handles deleting an ingredient
|
||||
func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/ingredients/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteIngredient(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
335
handlers/meals.go
Normal file
335
handlers/meals.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"mealprep/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MealsHandler handles the meals page
|
||||
func MealsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
meals, err := database.GetAllMeals()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div id="meals-content">
|
||||
<h2>Meals</h2>
|
||||
|
||||
<form hx-post="/meals" hx-target="#meals-list" hx-swap="beforeend" class="add-form">
|
||||
<input type="text" name="name" placeholder="Meal name" required />
|
||||
<input type="text" name="description" placeholder="Description" />
|
||||
<select name="meal_type" required>
|
||||
<option value="">Select type...</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="snack">Snack</option>
|
||||
</select>
|
||||
<button type="submit">Add Meal</button>
|
||||
</form>
|
||||
|
||||
<div id="meals-list" class="items-list">
|
||||
{{range .}}
|
||||
<div class="meal-item" id="meal-{{.ID}}">
|
||||
<div class="meal-header">
|
||||
<div>
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
||||
<span class="item-description">{{.Description}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
t := template.Must(template.New("meals").Parse(tmpl))
|
||||
t.Execute(w, meals)
|
||||
}
|
||||
|
||||
// AddMealHandler handles adding a new meal
|
||||
func AddMealHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
id, err := database.AddMeal(name, description, mealType)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div class="meal-item" id="meal-{{.ID}}">
|
||||
<div class="meal-header">
|
||||
<div>
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
|
||||
<span class="item-description">{{.Description}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
MealType string
|
||||
}{id, name, description, mealType}
|
||||
|
||||
t := template.Must(template.New("meal").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// DeleteMealHandler handles deleting a meal
|
||||
func DeleteMealHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/meals/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteMeal(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetMealIngredientsHandler shows ingredients for a specific meal
|
||||
func GetMealIngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
mealIngredients, err := database.GetMealIngredients(mealID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
allIngredients, err := database.GetAllIngredients()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div class="ingredients-section">
|
||||
<h4>Ingredients:</h4>
|
||||
|
||||
<form hx-post="/meals/{{.MealID}}/ingredients"
|
||||
hx-target="#meal-{{.MealID}}-ingredients-list"
|
||||
hx-swap="beforeend"
|
||||
class="add-ingredient-form">
|
||||
<select name="ingredient_id" required>
|
||||
<option value="">Select ingredient</option>
|
||||
{{range .AllIngredients}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Unit}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<input type="number" name="quantity" placeholder="Quantity" step="0.01" min="0" required />
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<div id="meal-{{.MealID}}-ingredients-list" class="sub-items-list">
|
||||
{{range .MealIngredients}}
|
||||
<div class="sub-item" id="meal-{{.MealID}}-ingredient-{{.IngredientID}}">
|
||||
<span>{{.IngredientName}}: {{.Quantity}} {{.Unit}}</span>
|
||||
<button
|
||||
hx-delete="/meals/{{.MealID}}/ingredients/{{.IngredientID}}"
|
||||
hx-target="#meal-{{.MealID}}-ingredient-{{.IngredientID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
MealID int
|
||||
MealIngredients interface{}
|
||||
AllIngredients interface{}
|
||||
}{
|
||||
MealID: mealID,
|
||||
MealIngredients: mealIngredients,
|
||||
AllIngredients: allIngredients,
|
||||
}
|
||||
|
||||
t := template.Must(template.New("mealIngredients").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// AddMealIngredientHandler adds an ingredient to a meal
|
||||
func AddMealIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ingredientID, err := strconv.Atoi(r.FormValue("ingredient_id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
quantity, err := strconv.ParseFloat(r.FormValue("quantity"), 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid quantity", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddMealIngredient(mealID, ingredientID, quantity); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the ingredient details to display
|
||||
ingredients, err := database.GetMealIngredients(mealID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the ingredient we just added
|
||||
var addedIngredient *interface{}
|
||||
for _, ing := range ingredients {
|
||||
if ing.IngredientID == ingredientID {
|
||||
var temp interface{} = ing
|
||||
addedIngredient = &temp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if addedIngredient == nil {
|
||||
http.Error(w, "Could not find added ingredient", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div class="sub-item" id="meal-{{.MealID}}-ingredient-{{.IngredientID}}">
|
||||
<span>{{.IngredientName}}: {{.Quantity}} {{.Unit}}</span>
|
||||
<button
|
||||
hx-delete="/meals/{{.MealID}}/ingredients/{{.IngredientID}}"
|
||||
hx-target="#meal-{{.MealID}}-ingredient-{{.IngredientID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
t := template.Must(template.New("mealIngredient").Parse(tmpl))
|
||||
t.Execute(w, *addedIngredient)
|
||||
}
|
||||
|
||||
// DeleteMealIngredientHandler removes an ingredient from a meal
|
||||
func DeleteMealIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) < 5 {
|
||||
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
|
||||
}
|
||||
|
||||
ingredientID, err := strconv.Atoi(parts[4])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteMealIngredient(mealID, ingredientID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
321
handlers/weekplan.go
Normal file
321
handlers/weekplan.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"mealprep/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WeekPlanHandler handles the week plan page
|
||||
func WeekPlanHandler(w http.ResponseWriter, r *http.Request) {
|
||||
weekPlan, err := database.GetWeekPlan()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
meals, err := database.GetAllMeals()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Group by date
|
||||
planByDate := make(map[string][]interface{})
|
||||
for _, entry := range weekPlan {
|
||||
dateStr := entry.Date.Format("2006-01-02")
|
||||
planByDate[dateStr] = append(planByDate[dateStr], entry)
|
||||
}
|
||||
|
||||
// Get next 7 days starting from upcoming Monday (or current Monday if today is Monday)
|
||||
today := time.Now()
|
||||
|
||||
// Calculate the upcoming Monday
|
||||
// If today is Monday, use today. Otherwise, find next Monday.
|
||||
weekday := int(today.Weekday())
|
||||
daysUntilMonday := (8 - weekday) % 7
|
||||
if weekday == 1 { // Today is Monday
|
||||
daysUntilMonday = 0
|
||||
}
|
||||
|
||||
startDate := today.AddDate(0, 0, daysUntilMonday)
|
||||
|
||||
var dates []string
|
||||
for i := 0; i < 7; i++ {
|
||||
date := startDate.AddDate(0, 0, i)
|
||||
dates = append(dates, date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div id="weekplan-content">
|
||||
<h2>Week Plan</h2>
|
||||
<p class="info">Planning week: {{.WeekStart}} - {{.WeekEnd}}</p>
|
||||
|
||||
<div class="week-container">
|
||||
{{range $index, $date := .Dates}}
|
||||
<div class="day-card">
|
||||
<h3>{{formatDate $date}}</h3>
|
||||
|
||||
<!-- Breakfast Section -->
|
||||
<div class="meal-type-section">
|
||||
<h4 class="meal-type-header">🌅 Breakfast</h4>
|
||||
<div class="day-meals" id="day-{{$date}}-breakfast">
|
||||
{{$entries := index $.PlanByDate $date}}
|
||||
{{range $entries}}
|
||||
{{if eq .MealType "breakfast"}}
|
||||
<div class="planned-meal" id="plan-{{.ID}}">
|
||||
<span>{{.MealName}}</span>
|
||||
<button
|
||||
hx-delete="/week-plan/{{.ID}}"
|
||||
hx-target="#plan-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<form hx-post="/week-plan"
|
||||
hx-target="#day-{{$date}}-breakfast"
|
||||
hx-swap="beforeend"
|
||||
class="add-meal-to-day">
|
||||
<input type="hidden" name="date" value="{{$date}}" />
|
||||
<input type="hidden" name="meal_type" value="breakfast" />
|
||||
<select name="meal_id" required>
|
||||
<option value="">Add breakfast...</option>
|
||||
{{range $.BreakfastMeals}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Lunch Section -->
|
||||
<div class="meal-type-section">
|
||||
<h4 class="meal-type-header">🍽️ Lunch</h4>
|
||||
<div class="day-meals" id="day-{{$date}}-lunch">
|
||||
{{$entries := index $.PlanByDate $date}}
|
||||
{{range $entries}}
|
||||
{{if eq .MealType "lunch"}}
|
||||
<div class="planned-meal" id="plan-{{.ID}}">
|
||||
<span>{{.MealName}}</span>
|
||||
<button
|
||||
hx-delete="/week-plan/{{.ID}}"
|
||||
hx-target="#plan-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<form hx-post="/week-plan"
|
||||
hx-target="#day-{{$date}}-lunch"
|
||||
hx-swap="beforeend"
|
||||
class="add-meal-to-day">
|
||||
<input type="hidden" name="date" value="{{$date}}" />
|
||||
<input type="hidden" name="meal_type" value="lunch" />
|
||||
<select name="meal_id" required>
|
||||
<option value="">Add lunch...</option>
|
||||
{{range $.LunchMeals}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Snack Section -->
|
||||
<div class="meal-type-section">
|
||||
<h4 class="meal-type-header">🍪 Snack</h4>
|
||||
<div class="day-meals" id="day-{{$date}}-snack">
|
||||
{{$entries := index $.PlanByDate $date}}
|
||||
{{range $entries}}
|
||||
{{if eq .MealType "snack"}}
|
||||
<div class="planned-meal" id="plan-{{.ID}}">
|
||||
<span>{{.MealName}}</span>
|
||||
<button
|
||||
hx-delete="/week-plan/{{.ID}}"
|
||||
hx-target="#plan-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<form hx-post="/week-plan"
|
||||
hx-target="#day-{{$date}}-snack"
|
||||
hx-swap="beforeend"
|
||||
class="add-meal-to-day">
|
||||
<input type="hidden" name="date" value="{{$date}}" />
|
||||
<input type="hidden" name="meal_type" value="snack" />
|
||||
<select name="meal_id" required>
|
||||
<option value="">Add snack...</option>
|
||||
{{range $.SnackMeals}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit">+</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": func(dateStr string) string {
|
||||
date, _ := time.Parse("2006-01-02", dateStr)
|
||||
// Show full date with day name
|
||||
return date.Format("Monday, Jan 2")
|
||||
},
|
||||
"index": func(m map[string][]interface{}, key string) []interface{} {
|
||||
return m[key]
|
||||
},
|
||||
}
|
||||
|
||||
// Format week range for display
|
||||
weekStart := startDate.Format("Mon, Jan 2")
|
||||
weekEnd := startDate.AddDate(0, 0, 6).Format("Mon, Jan 2")
|
||||
|
||||
// Separate meals by type
|
||||
var breakfastMeals, lunchMeals, snackMeals []interface{}
|
||||
for _, meal := range meals {
|
||||
switch meal.MealType {
|
||||
case "breakfast":
|
||||
breakfastMeals = append(breakfastMeals, meal)
|
||||
case "lunch":
|
||||
lunchMeals = append(lunchMeals, meal)
|
||||
case "snack":
|
||||
snackMeals = append(snackMeals, meal)
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Dates []string
|
||||
PlanByDate map[string][]interface{}
|
||||
BreakfastMeals interface{}
|
||||
LunchMeals interface{}
|
||||
SnackMeals interface{}
|
||||
WeekStart string
|
||||
WeekEnd string
|
||||
}{
|
||||
Dates: dates,
|
||||
PlanByDate: planByDate,
|
||||
BreakfastMeals: breakfastMeals,
|
||||
LunchMeals: lunchMeals,
|
||||
SnackMeals: snackMeals,
|
||||
WeekStart: weekStart,
|
||||
WeekEnd: weekEnd,
|
||||
}
|
||||
|
||||
t := template.Must(template.New("weekplan").Funcs(funcMap).Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// AddWeekPlanEntryHandler adds a meal to a specific day
|
||||
func AddWeekPlanEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dateStr := r.FormValue("date")
|
||||
mealType := r.FormValue("meal_type")
|
||||
mealID, err := strconv.Atoi(r.FormValue("meal_id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid meal ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate meal type
|
||||
if mealType != "breakfast" && mealType != "lunch" && mealType != "snack" {
|
||||
http.Error(w, "Invalid meal type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddWeekPlanEntry(date, mealID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the meal details
|
||||
meal, err := database.GetMealByID(mealID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify meal type matches
|
||||
if meal.MealType != mealType {
|
||||
http.Error(w, "Meal type does not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the ID of the entry we just added
|
||||
weekPlan, err := database.GetWeekPlan()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var entryID int
|
||||
for _, entry := range weekPlan {
|
||||
if entry.Date.Format("2006-01-02") == dateStr && entry.MealID == mealID {
|
||||
entryID = entry.ID
|
||||
}
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<div class="planned-meal" id="plan-{{.ID}}">
|
||||
<span>{{.MealName}}</span>
|
||||
<button
|
||||
hx-delete="/week-plan/{{.ID}}"
|
||||
hx-target="#plan-{{.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
class="delete-btn small">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
ID int
|
||||
MealName string
|
||||
}{entryID, meal.Name}
|
||||
|
||||
t := template.Must(template.New("planEntry").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// DeleteWeekPlanEntryHandler removes a meal from the week plan
|
||||
func DeleteWeekPlanEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/week-plan/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteWeekPlanEntry(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
218
main.go
Normal file
218
main.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"mealprep/database"
|
||||
"mealprep/handlers"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Print startup banner
|
||||
printBanner()
|
||||
|
||||
// Initialize database
|
||||
if err := database.InitDB("mealprep.db"); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer database.DB.Close()
|
||||
|
||||
log.Println("✅ Database initialized successfully")
|
||||
|
||||
// Static files
|
||||
fs := http.FileServer(http.Dir("static"))
|
||||
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||
|
||||
// Routes
|
||||
http.HandleFunc("/", indexHandler)
|
||||
|
||||
// Ingredients
|
||||
http.HandleFunc("/ingredients", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
handlers.IngredientsHandler(w, r)
|
||||
case "POST":
|
||||
handlers.AddIngredientHandler(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
http.HandleFunc("/ingredients/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" {
|
||||
handlers.DeleteIngredientHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
// Meals
|
||||
http.HandleFunc("/meals", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
handlers.MealsHandler(w, r)
|
||||
case "POST":
|
||||
handlers.AddMealHandler(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
http.HandleFunc("/meals/", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if strings.Contains(path, "/ingredients") {
|
||||
// Meal ingredients routes
|
||||
if r.Method == "GET" {
|
||||
handlers.GetMealIngredientsHandler(w, r)
|
||||
} else if r.Method == "POST" {
|
||||
handlers.AddMealIngredientHandler(w, r)
|
||||
} else if r.Method == "DELETE" {
|
||||
handlers.DeleteMealIngredientHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
} else {
|
||||
// Meal delete route
|
||||
if r.Method == "DELETE" {
|
||||
handlers.DeleteMealHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Week Plan
|
||||
http.HandleFunc("/week-plan", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
handlers.WeekPlanHandler(w, r)
|
||||
case "POST":
|
||||
handlers.AddWeekPlanEntryHandler(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
http.HandleFunc("/week-plan/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" {
|
||||
handlers.DeleteWeekPlanEntryHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
// Grocery List
|
||||
http.HandleFunc("/grocery-list", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
handlers.GroceryListHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Start server
|
||||
port := "8080"
|
||||
fmt.Println()
|
||||
log.Printf("🚀 Server running on http://localhost:%s", port)
|
||||
log.Println("📝 Press Ctrl+C to stop the server")
|
||||
fmt.Println()
|
||||
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
||||
log.Fatalf("❌ Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func printBanner() {
|
||||
banner := `
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🍽️ MEAL PREP PLANNER 🍽️ ║
|
||||
║ ║
|
||||
║ Plan your meals • Generate grocery lists ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`
|
||||
fmt.Println(banner)
|
||||
log.Println("🔧 Initializing application...")
|
||||
}
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meal Prep Planner</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>🍽️ Meal Prep Planner</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<button class="tab active"
|
||||
hx-get="/ingredients"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="setActiveTab(this)">
|
||||
Ingredients
|
||||
</button>
|
||||
<button class="tab"
|
||||
hx-get="/meals"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="setActiveTab(this)">
|
||||
Meals
|
||||
</button>
|
||||
<button class="tab"
|
||||
hx-get="/week-plan"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="setActiveTab(this)">
|
||||
Week Plan
|
||||
</button>
|
||||
<button class="tab"
|
||||
hx-get="/grocery-list"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="setActiveTab(this)">
|
||||
Grocery List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="content" class="content">
|
||||
<div hx-get="/ingredients" hx-trigger="load"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setActiveTab(clickedTab) {
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
// Add active class to clicked tab
|
||||
clickedTab.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
t := template.Must(template.New("index").Parse(tmpl))
|
||||
if err := t.Execute(w, nil); err != nil {
|
||||
log.Printf("Error rendering template: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
44
models/models.go
Normal file
44
models/models.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Ingredient represents a food ingredient
|
||||
type Ingredient struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp"
|
||||
}
|
||||
|
||||
// Meal represents a meal recipe
|
||||
type Meal struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MealType string `json:"meal_type"` // "breakfast", "lunch", "snack"
|
||||
}
|
||||
|
||||
// MealIngredient represents an ingredient in a meal with its quantity
|
||||
type MealIngredient struct {
|
||||
MealID int `json:"meal_id"`
|
||||
IngredientID int `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
// Joined fields
|
||||
IngredientName string `json:"ingredient_name,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// WeekPlanEntry represents a meal planned for a specific date
|
||||
type WeekPlanEntry struct {
|
||||
ID int `json:"id"`
|
||||
Date time.Time `json:"date"`
|
||||
MealID int `json:"meal_id"`
|
||||
MealName string `json:"meal_name,omitempty"`
|
||||
MealType string `json:"meal_type,omitempty"` // "breakfast", "lunch", "snack"
|
||||
}
|
||||
|
||||
// GroceryItem represents an aggregated ingredient for shopping
|
||||
type GroceryItem struct {
|
||||
IngredientName string `json:"ingredient_name"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
94
sample_data.sql
Normal file
94
sample_data.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Sample data for Meal Prep Planner
|
||||
-- Run this to populate the database with example data for testing
|
||||
|
||||
-- Ingredients
|
||||
INSERT INTO ingredients (name, unit) VALUES
|
||||
('Chicken Breast', 'grams'),
|
||||
('Rice', 'grams'),
|
||||
('Broccoli', 'grams'),
|
||||
('Olive Oil', 'ml'),
|
||||
('Garlic', 'cloves'),
|
||||
('Onion', 'pieces'),
|
||||
('Tomatoes', 'pieces'),
|
||||
('Pasta', 'grams'),
|
||||
('Ground Beef', 'grams'),
|
||||
('Cheese', 'grams'),
|
||||
('Eggs', 'pieces'),
|
||||
('Milk', 'ml'),
|
||||
('Butter', 'grams'),
|
||||
('Lettuce', 'grams'),
|
||||
('Cucumber', 'pieces'),
|
||||
('Bell Pepper', 'pieces'),
|
||||
('Soy Sauce', 'ml'),
|
||||
('Salt', 'grams'),
|
||||
('Black Pepper', 'grams'),
|
||||
('Carrots', 'pieces');
|
||||
|
||||
-- Meals
|
||||
INSERT INTO meals (name, description) VALUES
|
||||
('Grilled Chicken with Rice', 'Healthy protein-packed meal with vegetables'),
|
||||
('Beef Pasta', 'Classic Italian-style pasta with ground beef'),
|
||||
('Chicken Stir Fry', 'Asian-inspired quick meal'),
|
||||
('Garden Salad', 'Fresh vegetable salad'),
|
||||
('Scrambled Eggs Breakfast', 'Quick and easy breakfast');
|
||||
|
||||
-- Meal Ingredients for Grilled Chicken with Rice (meal_id: 1)
|
||||
INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES
|
||||
(1, 1, 200), -- Chicken Breast
|
||||
(1, 2, 150), -- Rice
|
||||
(1, 3, 100), -- Broccoli
|
||||
(1, 4, 10), -- Olive Oil
|
||||
(1, 5, 2), -- Garlic
|
||||
(1, 18, 5), -- Salt
|
||||
(1, 19, 2); -- Black Pepper
|
||||
|
||||
-- Meal Ingredients for Beef Pasta (meal_id: 2)
|
||||
INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES
|
||||
(2, 8, 200), -- Pasta
|
||||
(2, 9, 150), -- Ground Beef
|
||||
(2, 7, 2), -- Tomatoes
|
||||
(2, 6, 1), -- Onion
|
||||
(2, 5, 3), -- Garlic
|
||||
(2, 4, 15), -- Olive Oil
|
||||
(2, 18, 5), -- Salt
|
||||
(2, 19, 2); -- Black Pepper
|
||||
|
||||
-- Meal Ingredients for Chicken Stir Fry (meal_id: 3)
|
||||
INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES
|
||||
(3, 1, 180), -- Chicken Breast
|
||||
(3, 16, 1), -- Bell Pepper
|
||||
(3, 20, 1), -- Carrots
|
||||
(3, 3, 80), -- Broccoli
|
||||
(3, 5, 2), -- Garlic
|
||||
(3, 17, 30), -- Soy Sauce
|
||||
(3, 4, 10); -- Olive Oil
|
||||
|
||||
-- Meal Ingredients for Garden Salad (meal_id: 4)
|
||||
INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES
|
||||
(4, 14, 100), -- Lettuce
|
||||
(4, 15, 1), -- Cucumber
|
||||
(4, 7, 2), -- Tomatoes
|
||||
(4, 20, 1), -- Carrots
|
||||
(4, 4, 20), -- Olive Oil
|
||||
(4, 18, 3); -- Salt
|
||||
|
||||
-- Meal Ingredients for Scrambled Eggs Breakfast (meal_id: 5)
|
||||
INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES
|
||||
(5, 11, 3), -- Eggs
|
||||
(5, 12, 50), -- Milk
|
||||
(5, 13, 10), -- Butter
|
||||
(5, 18, 2), -- Salt
|
||||
(5, 19, 1); -- Black Pepper
|
||||
|
||||
-- Week Plan (next 7 days)
|
||||
-- Using relative dates (you may need to adjust these based on current date)
|
||||
INSERT INTO week_plan (date, meal_id) VALUES
|
||||
(date('now'), 1), -- Today: Grilled Chicken with Rice
|
||||
(date('now', '+1 day'), 2), -- Tomorrow: Beef Pasta
|
||||
(date('now', '+1 day'), 4), -- Tomorrow: Garden Salad
|
||||
(date('now', '+2 days'), 3), -- Day 3: Chicken Stir Fry
|
||||
(date('now', '+3 days'), 1), -- Day 4: Grilled Chicken with Rice
|
||||
(date('now', '+4 days'), 5), -- Day 5: Scrambled Eggs Breakfast
|
||||
(date('now', '+4 days'), 4), -- Day 5: Garden Salad
|
||||
(date('now', '+5 days'), 2), -- Day 6: Beef Pasta
|
||||
(date('now', '+6 days'), 3); -- Day 7: Chicken Stir Fry
|
||||
49
start.sh
Executable file
49
start.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Meal Prep Planner - Quick Start Script
|
||||
|
||||
set -e
|
||||
|
||||
echo "🍽️ Meal Prep Planner - Setup & Start"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Check if Go is installed
|
||||
if ! command -v go &> /dev/null
|
||||
then
|
||||
echo "❌ Go is not installed!"
|
||||
echo ""
|
||||
echo "Please install Go first:"
|
||||
echo " macOS: brew install go"
|
||||
echo " Linux: https://go.dev/doc/install"
|
||||
echo " Windows: https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Go is installed: $(go version)"
|
||||
echo ""
|
||||
|
||||
# Install dependencies
|
||||
echo "📦 Installing dependencies..."
|
||||
go mod tidy
|
||||
go mod download
|
||||
echo "✅ Dependencies installed"
|
||||
echo ""
|
||||
|
||||
# Build the application
|
||||
echo "🔨 Building application..."
|
||||
go build -o mealprep main.go
|
||||
echo "✅ Build complete"
|
||||
echo ""
|
||||
|
||||
# Start the server
|
||||
echo "🚀 Starting server..."
|
||||
echo ""
|
||||
echo " Access the application at: http://localhost:8080"
|
||||
echo ""
|
||||
echo " Press Ctrl+C to stop the server"
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
./mealprep
|
||||
446
static/styles.css
Normal file
446
static/styles.css
Normal file
@@ -0,0 +1,446 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #2c3e50;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #2c3e50;
|
||||
border-bottom-color: #3498db;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #34495e;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: #34495e;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.add-form,
|
||||
.add-ingredient-form,
|
||||
.add-meal-to-day {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="date"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background-color: #95a5a6;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.delete-btn.small {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.item-unit {
|
||||
color: #7f8c8d;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Meals */
|
||||
.meal-item {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meal-header > div {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meal-ingredients {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ingredients-section {
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.sub-items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sub-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: #e8f4f8;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Week Plan */
|
||||
.week-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.day-card {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.day-card h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.day-meals {
|
||||
min-height: 100px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.planned-meal {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
border-left: 3px solid #2ecc71;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meal-type-section {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.meal-type-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meal-type-header {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meal-type-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tag-breakfast {
|
||||
background-color: #f39c12;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-lunch {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-snack {
|
||||
background-color: #9b59b6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-meal-to-day {
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.add-meal-to-day select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.add-meal-to-day button {
|
||||
padding: 8px 12px;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.day-meals {
|
||||
min-height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Grocery List */
|
||||
.grocery-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.grocery-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #2ecc71;
|
||||
}
|
||||
|
||||
.grocery-name {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.grocery-quantity {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.add-form,
|
||||
.add-ingredient-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meal-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.week-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.htmx-request {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.item,
|
||||
.meal-item,
|
||||
.planned-meal,
|
||||
.grocery-item {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
Reference in New Issue
Block a user