diff --git a/IMPLEMENTATION_NOTES.txt b/IMPLEMENTATION_NOTES.txt index 11338aa..e4c4502 100644 --- a/IMPLEMENTATION_NOTES.txt +++ b/IMPLEMENTATION_NOTES.txt @@ -1,76 +1,123 @@ -MEAL ENHANCEMENTS - WORKING! +EDIT MEAL FEATURE - IMPLEMENTED -=== ✅ IMPLEMENTATION COMPLETE === +=== ✅ NEW FEATURE === -Meals now have: -1. Instructions (multi-line) -2. Prep time (minutes) -3. Image (URL) +You can now EDIT existing meals! -=== IF MEALS/WEEK PLAN TABS DON'T LOAD === +Click "Edit" button → Modal opens → Make changes → Save -Your database needs the new columns! +=== HOW IT WORKS === -SOLUTION - Option 1 (Fresh start): -rm mealprep.db -./start.sh - -SOLUTION - Option 2 (Keep data): -Just restart the server - migration runs automatically! -./start.sh - -The migration will: -- Check if new columns exist -- Add them if missing (instructions, prep_time, image_url) -- Keep all your existing data -- No data loss - -=== HOW TO VERIFY === - -After restart: -1. Go to Meals tab -2. Form should have: +1. Click "Edit" button on any meal +2. Modal dialog opens with form +3. All fields pre-filled with current values: - Name - - Description - - Type dropdown - - Prep time (NEW) - - Image URL (NEW) - - Instructions textarea (NEW) + - Description + - Type (breakfast/lunch/snack) + - Prep time + - Image URL + - Instructions +4. Change what you want +5. Click "Save" → Modal closes, meal updates +6. Click "Cancel" → Modal closes, no changes -If you see the new fields, it's working! +=== SECURITY === -=== FEATURES === +✅ User isolation enforced: + - GetMealByID(userID, mealID) verifies ownership + - Users CANNOT edit others' meals + - Users CANNOT access others' meal data + - UPDATE query filters by user_id + - All queries parameterized (SQL injection safe) -Instructions: -- Multi-line textarea -- Click to expand/collapse on meal card -- Optional +✅ Modal security: + - Closes on click outside + - Close button works + - No data exposed + - XSS protected (template escaping) -Prep Time: -- Number input (minutes) -- Shows as "⏱️ XX min" badge -- Optional +=== UI FEATURES === -Image: -- URL input -- Shows as 120x120px thumbnail -- Optional +Modal Dialog: +- Semi-transparent overlay +- Centered white box +- All fields editable +- Save button (blue) +- Cancel button (gray) +- Click outside to close +- Clean, professional design -=== ALL FIELDS OPTIONAL === +Buttons: +- Edit (orange) - opens modal +- Save (blue) - updates meal +- Cancel (gray) - closes modal -You can: -- Leave them blank -- Fill only some -- Fill all of them +=== WHAT CAN BE EDITED === -Old meals without these fields work fine! +Everything: +- ✅ Name +- ✅ Description +- ✅ Meal type (breakfast/lunch/snack) +- ✅ Prep time +- ✅ Image URL +- ✅ Instructions + +=== AFTER SAVE === + +- Modal closes automatically +- Meal card updates instantly +- No page reload (HTMX) +- All changes visible immediately +- Edit button still works + +=== CODE CHANGES === + +handlers/meals.go: +- GetEditMealHandler() - shows modal with form +- UpdateMealHandler() - saves changes with security +- Added Edit button to meal cards + +main.go: +- /meals/:id/edit route (GET) +- /meals/:id/update route (POST) + +static/styles.css: +- Modal overlay styles +- Modal content styles +- Form styles +- Button styles + +=== SECURITY CHECKS === + +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 + +Update: +1. Check session (middleware) +2. Get userID from context +3. Verify meal ownership BEFORE update +4. Validate all inputs +5. Validate meal type +6. UPDATE with user_id filter +7. Return 403 if unauthorized + +SQL Queries: +- All parameterized +- No string concatenation +- User isolation enforced +- No SQL injection possible === READY TO USE === -✅ Migration included -✅ Auto-updates old databases -✅ No data loss -✅ All features work +✅ Build successful +✅ Security implemented +✅ User isolation working +✅ Modal working +✅ All features preserved -Just restart the server and you're good! +Just restart if needed! diff --git a/handlers/meals.go b/handlers/meals.go index c6ab1b8..4d86eef 100644 --- a/handlers/meals.go +++ b/handlers/meals.go @@ -61,6 +61,13 @@ func MealsHandler(w http.ResponseWriter, r *http.Request) { {{end}}
+
+ + +
` @@ -149,6 +159,13 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) { {{end}}
+ + +
+ + + + + ` + + 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 := ` +
+
+ {{if .ImageURL}} + {{.Name}} + {{end}} +
+
+ {{.Name}} + {{.MealType}} + {{if gt .PrepTime 0}} + ⏱️ {{.PrepTime}} min + {{end}} +
+ {{.Description}} + {{if .Instructions}} +
+ Instructions +

{{.Instructions}}

+
+ {{end}} +
+
+ + + +
+
+
+
+ + ` + + 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) +} diff --git a/main.go b/main.go index 182cf3b..f4c72b5 100644 --- a/main.go +++ b/main.go @@ -94,6 +94,20 @@ func main() { } else { 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 { // Meal delete route if r.Method == "DELETE" { diff --git a/static/styles.css b/static/styles.css index 9b99ccd..a90d621 100644 --- a/static/styles.css +++ b/static/styles.css @@ -587,6 +587,125 @@ button:hover { 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 */ @media (max-width: 768px) { .container {