Compare commits

..

2 Commits

Author SHA1 Message Date
b8046c87b9 added info to meal 2025-10-25 16:17:50 +02:00
38db9c242b added info to meal 2025-10-25 16:06:42 +02:00
6 changed files with 638 additions and 158 deletions

View File

@@ -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!

View File

@@ -29,6 +29,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
} }
@@ -73,6 +78,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 +113,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) {
@@ -220,7 +255,7 @@ func DeleteIngredient(userID, ingredientID int) error {
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 +266,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 +277,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

View File

@@ -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">
<div> {{if .ImageURL}}
<span class="item-name">{{.Name}}</span> <img src="{{.ImageURL}}" alt="{{.Name}}" class="meal-image" />
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span> {{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> <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">
<div> {{if .ImageURL}}
<span class="item-name">{{.Name}}</span> <img src="{{.ImageURL}}" alt="{{.Name}}" class="meal-image" />
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span> {{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> <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"
@@ -133,11 +188,14 @@ func AddMealHandler(w http.ResponseWriter, r *http.Request) {
` `
data := struct { data := struct {
ID int64 ID int64
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)
}

14
main.go
View File

@@ -94,6 +94,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" {

View File

@@ -28,11 +28,14 @@ type Ingredient struct {
// Meal represents a meal recipe // Meal represents a meal recipe
type Meal struct { type Meal struct {
ID int `json:"id"` ID int `json:"id"`
UserID int `json:"user_id"` UserID int `json:"user_id"`
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

View File

@@ -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;
@@ -514,6 +587,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 {