Compare commits
4 Commits
b8046c87b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4221e20379 | |||
| 52446f39bb | |||
| 335c34ce64 | |||
| cc28aa9a8e |
54
.dockerignore
Normal file
54
.dockerignore
Normal file
@@ -0,0 +1,54 @@
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Binaries
|
||||
mealprep
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test and coverage files
|
||||
*.test
|
||||
*.out
|
||||
coverage.txt
|
||||
*.coverprofile
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
*.txt
|
||||
IMPLEMENTATION_NOTES.txt
|
||||
SECURITY_REPORT.txt
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Docker files (no need to include in image)
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-bookworm AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 go build -o mealprep -ldflags="-s -w" .
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
sqlite3 \
|
||||
libsqlite3-0 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app directory and data directory
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /build/mealprep .
|
||||
|
||||
# Copy static files
|
||||
COPY --from=builder /build/static ./static
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set environment variables
|
||||
ENV DB_PATH=/app/data/mealprep.db
|
||||
|
||||
# Run the application
|
||||
CMD ["./mealprep"]
|
||||
106
Makefile
106
Makefile
@@ -1,10 +1,13 @@
|
||||
.PHONY: run build clean test deps install
|
||||
.PHONY: all run build clean test deps install docker-build docker-up docker-down docker-logs docker-restart docker-clean help
|
||||
|
||||
# Run the application
|
||||
# Default target: build and start with Docker
|
||||
all: docker-up
|
||||
|
||||
# Run the application locally
|
||||
run:
|
||||
go run main.go
|
||||
|
||||
# Build the binary
|
||||
# Build the binary locally
|
||||
build:
|
||||
go build -o mealprep main.go
|
||||
|
||||
@@ -33,14 +36,95 @@ test:
|
||||
install:
|
||||
go install
|
||||
|
||||
# Docker: Build the Docker image
|
||||
docker-build:
|
||||
docker build -t mealprep:latest .
|
||||
|
||||
# Docker: Build and start containers
|
||||
docker-up:
|
||||
docker compose up -d
|
||||
|
||||
# Docker: Build and start with logs
|
||||
docker-up-logs:
|
||||
docker compose up --build
|
||||
|
||||
# Docker: Stop and remove containers
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
# Docker: View logs
|
||||
docker-logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Docker: Restart containers
|
||||
docker-restart:
|
||||
docker compose restart
|
||||
|
||||
# Docker: Stop containers
|
||||
docker-stop:
|
||||
docker compose stop
|
||||
|
||||
# Docker: Start containers
|
||||
docker-start:
|
||||
docker compose start
|
||||
|
||||
# Docker: Clean everything (containers, volumes, images)
|
||||
docker-clean:
|
||||
docker compose down -v
|
||||
docker rmi mealprep:latest 2>/dev/null || true
|
||||
|
||||
# Docker: Rebuild and restart
|
||||
docker-rebuild:
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Docker: Shell into container
|
||||
docker-shell:
|
||||
docker exec -it mealprep-app /bin/sh
|
||||
|
||||
# Docker: Show container status
|
||||
docker-status:
|
||||
docker compose ps
|
||||
|
||||
# Deploy: Build and deploy to production
|
||||
deploy: docker-build
|
||||
@echo "Building and deploying to Docker server..."
|
||||
docker compose up -d --build
|
||||
@echo "Deployment complete!"
|
||||
|
||||
# Show help
|
||||
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"
|
||||
@echo ""
|
||||
@echo "Default:"
|
||||
@echo " make / all - Build and start with Docker (default)"
|
||||
@echo ""
|
||||
@echo "Local Development:"
|
||||
@echo " run - Run the application locally"
|
||||
@echo " build - Build the binary locally"
|
||||
@echo " deps - Install dependencies"
|
||||
@echo " clean - Remove binary and database"
|
||||
@echo " clean-db - Remove only database"
|
||||
@echo " dev - Run with auto-reload (requires air)"
|
||||
@echo " test - Run tests"
|
||||
@echo " install - Install binary to GOPATH"
|
||||
@echo ""
|
||||
@echo "Docker Commands:"
|
||||
@echo " docker-build - Build the Docker image"
|
||||
@echo " docker-up - Build and start containers in background"
|
||||
@echo " docker-up-logs - Build and start with logs attached"
|
||||
@echo " docker-down - Stop and remove containers"
|
||||
@echo " docker-logs - View container logs"
|
||||
@echo " docker-restart - Restart containers"
|
||||
@echo " docker-stop - Stop containers"
|
||||
@echo " docker-start - Start stopped containers"
|
||||
@echo " docker-clean - Remove everything (containers, volumes, images)"
|
||||
@echo " docker-rebuild - Rebuild from scratch and start"
|
||||
@echo " docker-shell - Open shell in container"
|
||||
@echo " docker-status - Show container status"
|
||||
@echo ""
|
||||
@echo "Deployment:"
|
||||
@echo " deploy - Build and deploy to production"
|
||||
@echo ""
|
||||
@echo " help - Show this help message"
|
||||
|
||||
267
database/db.go
267
database/db.go
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mealprep/models"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -71,6 +72,26 @@ func createTables() error {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ingredients_user_id ON ingredients(user_id);
|
||||
|
||||
-- Tags table (user-isolated)
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
|
||||
|
||||
-- Ingredient tags junction table
|
||||
CREATE TABLE IF NOT EXISTS ingredient_tags (
|
||||
ingredient_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (ingredient_id, tag_id),
|
||||
FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Meals table (user-isolated)
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -248,7 +269,14 @@ func DeleteIngredient(userID, ingredientID int) error {
|
||||
"DELETE FROM ingredients WHERE id = ? AND user_id = ?",
|
||||
ingredientID, userID,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up unused tags
|
||||
CleanupUnusedTags(userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Meal operations (user-isolated)
|
||||
@@ -454,3 +482,240 @@ func GetGroceryList(userID int) ([]models.GroceryItem, error) {
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Tag operations (user-isolated)
|
||||
|
||||
func GetAllTags(userID int) ([]models.Tag, error) {
|
||||
rows, err := DB.Query(
|
||||
"SELECT id, user_id, name FROM tags WHERE user_id = ? ORDER BY name",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []models.Tag
|
||||
for rows.Next() {
|
||||
var tag models.Tag
|
||||
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func GetUsedTags(userID int) ([]models.Tag, error) {
|
||||
query := `
|
||||
SELECT DISTINCT t.id, t.user_id, t.name
|
||||
FROM tags t
|
||||
JOIN ingredient_tags it ON t.id = it.tag_id
|
||||
WHERE t.user_id = ?
|
||||
ORDER BY t.name
|
||||
`
|
||||
rows, err := DB.Query(query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []models.Tag
|
||||
for rows.Next() {
|
||||
var tag models.Tag
|
||||
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func CleanupUnusedTags(userID int) error {
|
||||
// Delete tags that have no ingredient associations
|
||||
_, err := DB.Exec(`
|
||||
DELETE FROM tags
|
||||
WHERE user_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT DISTINCT tag_id FROM ingredient_tags
|
||||
)
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func AddTag(userID int, name string) (int64, error) {
|
||||
result, err := DB.Exec(
|
||||
"INSERT INTO tags (user_id, name) VALUES (?, ?)",
|
||||
userID, name,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func GetOrCreateTag(userID int, name string) (int64, error) {
|
||||
// Try to get existing tag
|
||||
var tagID int64
|
||||
err := DB.QueryRow(
|
||||
"SELECT id FROM tags WHERE user_id = ? AND name = ?",
|
||||
userID, name,
|
||||
).Scan(&tagID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Tag doesn't exist, create it
|
||||
return AddTag(userID, name)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tagID, nil
|
||||
}
|
||||
|
||||
func DeleteTag(userID, tagID int) error {
|
||||
_, err := DB.Exec(
|
||||
"DELETE FROM tags WHERE id = ? AND user_id = ?",
|
||||
tagID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Ingredient tag operations
|
||||
|
||||
func GetIngredientTags(userID, ingredientID int) ([]models.Tag, error) {
|
||||
query := `
|
||||
SELECT t.id, t.user_id, t.name
|
||||
FROM tags t
|
||||
JOIN ingredient_tags it ON t.id = it.tag_id
|
||||
JOIN ingredients i ON it.ingredient_id = i.id
|
||||
WHERE it.ingredient_id = ? AND i.user_id = ? AND t.user_id = ?
|
||||
ORDER BY t.name
|
||||
`
|
||||
rows, err := DB.Query(query, ingredientID, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []models.Tag
|
||||
for rows.Next() {
|
||||
var tag models.Tag
|
||||
if err := rows.Scan(&tag.ID, &tag.UserID, &tag.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func AddIngredientTag(userID, ingredientID, tagID int) error {
|
||||
// Verify ingredient and tag belong to user
|
||||
var count int
|
||||
err := DB.QueryRow(
|
||||
"SELECT COUNT(*) FROM ingredients i, tags t WHERE i.id = ? AND t.id = ? AND i.user_id = ? AND t.user_id = ?",
|
||||
ingredientID, tagID, userID, userID,
|
||||
).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return fmt.Errorf("ingredient or tag not found or doesn't belong to user")
|
||||
}
|
||||
|
||||
_, err = DB.Exec(
|
||||
"INSERT OR IGNORE INTO ingredient_tags (ingredient_id, tag_id) VALUES (?, ?)",
|
||||
ingredientID, tagID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveIngredientTag(userID, ingredientID, tagID int) error {
|
||||
// Verify ownership
|
||||
var count int
|
||||
err := DB.QueryRow(
|
||||
"SELECT COUNT(*) FROM ingredients WHERE id = ? AND user_id = ?",
|
||||
ingredientID, userID,
|
||||
).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return fmt.Errorf("ingredient not found or doesn't belong to user")
|
||||
}
|
||||
|
||||
_, err = DB.Exec(
|
||||
"DELETE FROM ingredient_tags WHERE ingredient_id = ? AND tag_id = ?",
|
||||
ingredientID, tagID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up unused tags
|
||||
CleanupUnusedTags(userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetIngredientsWithTags(userID int) ([]models.Ingredient, error) {
|
||||
rows, err := DB.Query(
|
||||
"SELECT id, user_id, name, unit FROM ingredients WHERE user_id = ? ORDER BY name",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ingredients []models.Ingredient
|
||||
for rows.Next() {
|
||||
var ing models.Ingredient
|
||||
if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get tags for this ingredient
|
||||
ing.Tags, _ = GetIngredientTags(userID, ing.ID)
|
||||
ingredients = append(ingredients, ing)
|
||||
}
|
||||
return ingredients, nil
|
||||
}
|
||||
|
||||
func SearchIngredientsByTags(userID int, tagNames []string) ([]models.Ingredient, error) {
|
||||
if len(tagNames) == 0 {
|
||||
return GetIngredientsWithTags(userID)
|
||||
}
|
||||
|
||||
// Build query with placeholders for tag names
|
||||
placeholders := make([]string, len(tagNames))
|
||||
args := []interface{}{userID}
|
||||
for i, name := range tagNames {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, name)
|
||||
}
|
||||
args = append(args, len(tagNames))
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT i.id, i.user_id, i.name, i.unit
|
||||
FROM ingredients i
|
||||
JOIN ingredient_tags it ON i.id = it.ingredient_id
|
||||
JOIN tags t ON it.tag_id = t.id
|
||||
WHERE i.user_id = ? AND t.name IN (%s)
|
||||
GROUP BY i.id
|
||||
HAVING COUNT(DISTINCT t.id) = ?
|
||||
ORDER BY i.name
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ingredients []models.Ingredient
|
||||
for rows.Next() {
|
||||
var ing models.Ingredient
|
||||
if err := rows.Scan(&ing.ID, &ing.UserID, &ing.Name, &ing.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get tags for this ingredient
|
||||
ing.Tags, _ = GetIngredientTags(userID, ing.ID)
|
||||
ingredients = append(ingredients, ing)
|
||||
}
|
||||
return ingredients, nil
|
||||
}
|
||||
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
mealprep:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mealprep-app
|
||||
ports:
|
||||
- "8090:8080"
|
||||
volumes:
|
||||
- mealprep-data:/app/data
|
||||
environment:
|
||||
- DB_PATH=/app/data/mealprep.db
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--quiet",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:8080/health",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
mealprep-data:
|
||||
driver: local
|
||||
@@ -12,7 +12,38 @@ import (
|
||||
// IngredientsHandler handles the ingredients page
|
||||
func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r)
|
||||
ingredients, err := database.GetAllIngredients(userID)
|
||||
|
||||
// Check if there's a search query
|
||||
searchTags := r.URL.Query().Get("tags")
|
||||
var ingredients []interface{}
|
||||
var err error
|
||||
|
||||
if searchTags != "" {
|
||||
tagNames := strings.Split(searchTags, ",")
|
||||
for i := range tagNames {
|
||||
tagNames[i] = strings.TrimSpace(tagNames[i])
|
||||
}
|
||||
ings, err := database.SearchIngredientsByTags(userID, tagNames)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ing := range ings {
|
||||
ingredients = append(ingredients, ing)
|
||||
}
|
||||
} else {
|
||||
ings, err := database.GetIngredientsWithTags(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ing := range ings {
|
||||
ingredients = append(ingredients, ing)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all available tags for the filter UI (only tags that are in use)
|
||||
allTags, err := database.GetUsedTags(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -25,30 +56,136 @@ func IngredientsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
<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 />
|
||||
<input type="text" name="tags" placeholder="Tags (comma-separated)" />
|
||||
<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
|
||||
<div class="search-section">
|
||||
<h3>Filter by Tags</h3>
|
||||
<div class="tag-filter">
|
||||
{{range .AllTags}}
|
||||
<button class="tag-filter-btn"
|
||||
onclick="toggleTagFilter('{{.Name}}')">
|
||||
{{.Name}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
<div id="active-filters" class="active-filters"></div>
|
||||
<button onclick="clearFilters()" class="clear-filters-btn">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="ingredients-list" class="items-list">
|
||||
{{range .Ingredients}}
|
||||
<div class="item" id="ingredient-{{.ID}}">
|
||||
<div class="item-content">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<div class="item-tags">
|
||||
{{range .Tags}}
|
||||
<span class="tag">{{.Name}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
hx-get="/ingredients/{{.ID}}/tags"
|
||||
hx-target="#tag-modal-content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('tag-modal').style.display='block'"
|
||||
class="edit-tags-btn">
|
||||
Edit Tags
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Edit Modal -->
|
||||
<div id="tag-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="document.getElementById('tag-modal').style.display='none'">×</span>
|
||||
<div id="tag-modal-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let activeFilters = new Set();
|
||||
|
||||
// Listen for tag changes and refresh ingredients
|
||||
document.body.addEventListener('refreshIngredients', function() {
|
||||
// Close the modal first
|
||||
document.getElementById('tag-modal').style.display='none';
|
||||
// Then refresh the page
|
||||
htmx.ajax('GET', '/ingredients', {target: '#ingredients-content', swap: 'innerHTML'});
|
||||
});
|
||||
|
||||
function toggleTagFilter(tagName) {
|
||||
if (activeFilters.has(tagName)) {
|
||||
activeFilters.delete(tagName);
|
||||
} else {
|
||||
activeFilters.add(tagName);
|
||||
}
|
||||
updateFilters();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters.clear();
|
||||
updateFilters();
|
||||
}
|
||||
|
||||
function updateFilters() {
|
||||
// Update active filters display
|
||||
const activeFiltersDiv = document.getElementById('active-filters');
|
||||
if (activeFilters.size > 0) {
|
||||
activeFiltersDiv.innerHTML = 'Active filters: ' + Array.from(activeFilters).join(', ');
|
||||
} else {
|
||||
activeFiltersDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
// Update filter button states
|
||||
document.querySelectorAll('.tag-filter-btn').forEach(btn => {
|
||||
if (activeFilters.has(btn.textContent.trim())) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Reload ingredients with filters
|
||||
const url = activeFilters.size > 0
|
||||
? '/ingredients?tags=' + Array.from(activeFilters).join(',')
|
||||
: '/ingredients';
|
||||
|
||||
htmx.ajax('GET', url, {target: '#ingredients-content', swap: 'innerHTML'});
|
||||
}
|
||||
</script>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
Ingredients []interface{}
|
||||
AllTags []interface{}
|
||||
}{
|
||||
Ingredients: ingredients,
|
||||
AllTags: []interface{}{},
|
||||
}
|
||||
|
||||
for _, tag := range allTags {
|
||||
data.AllTags = append(data.AllTags, tag)
|
||||
}
|
||||
|
||||
t := template.Must(template.New("ingredients").Parse(tmpl))
|
||||
t.Execute(w, ingredients)
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// AddIngredientHandler handles adding a new ingredient
|
||||
@@ -62,6 +199,7 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
unit := strings.TrimSpace(r.FormValue("unit"))
|
||||
tagsStr := strings.TrimSpace(r.FormValue("tags"))
|
||||
|
||||
if name == "" || unit == "" {
|
||||
http.Error(w, "Name and unit are required", http.StatusBadRequest)
|
||||
@@ -74,18 +212,54 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process tags if provided
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tagNames := strings.Split(tagsStr, ",")
|
||||
for _, tagName := range tagNames {
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
if tagName != "" {
|
||||
// Get or create tag
|
||||
tagID, err := database.GetOrCreateTag(userID, tagName)
|
||||
if err != nil {
|
||||
continue // Skip this tag on error
|
||||
}
|
||||
// Link tag to ingredient
|
||||
database.AddIngredientTag(userID, int(id), int(tagID))
|
||||
tags = append(tags, tagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<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 class="item-content">
|
||||
<span class="item-name">{{.Name}}</span>
|
||||
<span class="item-unit">({{.Unit}})</span>
|
||||
<div class="item-tags">
|
||||
{{range .Tags}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
hx-get="/ingredients/{{.ID}}/tags"
|
||||
hx-target="#tag-modal-content"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('tag-modal').style.display='block'"
|
||||
class="edit-tags-btn">
|
||||
Edit Tags
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -93,7 +267,8 @@ func AddIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ID int64
|
||||
Name string
|
||||
Unit string
|
||||
}{id, name, unit}
|
||||
Tags []string
|
||||
}{id, name, unit, tags}
|
||||
|
||||
t := template.Must(template.New("ingredient").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
@@ -117,3 +292,224 @@ func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetIngredientTagsHandler returns the tag editing UI for an ingredient
|
||||
func GetIngredientTagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r)
|
||||
|
||||
// Extract ingredient ID from path /ingredients/{id}/tags
|
||||
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||
if len(pathParts) < 1 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current tags for ingredient
|
||||
currentTags, err := database.GetIngredientTags(userID, ingredientID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all available tags
|
||||
allTags, err := database.GetAllTags(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a set of current tag IDs for easy lookup
|
||||
currentTagIDs := make(map[int]bool)
|
||||
for _, tag := range currentTags {
|
||||
currentTagIDs[tag.ID] = true
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
<h3>Edit Tags</h3>
|
||||
<div class="tag-edit-section">
|
||||
<h4>Current Tags</h4>
|
||||
<div id="current-tags-{{.IngredientID}}">
|
||||
{{range .CurrentTags}}
|
||||
<span class="tag">
|
||||
{{.Name}}
|
||||
<button
|
||||
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
|
||||
hx-target="#current-tags-{{$.IngredientID}}"
|
||||
hx-swap="innerHTML"
|
||||
class="tag-remove">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h4>Add Tags</h4>
|
||||
<form hx-post="/ingredients/{{.IngredientID}}/tags"
|
||||
hx-target="#current-tags-{{.IngredientID}}"
|
||||
hx-swap="innerHTML"
|
||||
class="add-tag-form">
|
||||
<input type="text" name="tag_name" placeholder="New tag name" list="existing-tags" required />
|
||||
<datalist id="existing-tags">
|
||||
{{range .AllTags}}
|
||||
{{if not (index $.CurrentTagIDs .ID)}}
|
||||
<option value="{{.Name}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
</datalist>
|
||||
<button type="submit">Add Tag</button>
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
|
||||
data := struct {
|
||||
IngredientID int
|
||||
CurrentTags []interface{}
|
||||
AllTags []interface{}
|
||||
CurrentTagIDs map[int]bool
|
||||
}{
|
||||
IngredientID: ingredientID,
|
||||
CurrentTags: []interface{}{},
|
||||
AllTags: []interface{}{},
|
||||
CurrentTagIDs: currentTagIDs,
|
||||
}
|
||||
|
||||
for _, tag := range currentTags {
|
||||
data.CurrentTags = append(data.CurrentTags, tag)
|
||||
}
|
||||
for _, tag := range allTags {
|
||||
data.AllTags = append(data.AllTags, tag)
|
||||
}
|
||||
|
||||
t := template.Must(template.New("ingredient-tags").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
// AddIngredientTagHandler adds a tag to an ingredient
|
||||
func AddIngredientTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r)
|
||||
|
||||
// Extract ingredient ID from path
|
||||
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||
if len(pathParts) < 1 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tagName := strings.TrimSpace(r.FormValue("tag_name"))
|
||||
if tagName == "" {
|
||||
http.Error(w, "Tag name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create the tag
|
||||
tagID, err := database.GetOrCreateTag(userID, tagName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Add tag to ingredient
|
||||
if err := database.AddIngredientTag(userID, ingredientID, int(tagID)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated tag list with trigger to refresh page
|
||||
w.Header().Set("HX-Trigger", "refreshIngredients")
|
||||
renderCurrentTags(w, userID, ingredientID)
|
||||
}
|
||||
|
||||
// RemoveIngredientTagHandler removes a tag from an ingredient
|
||||
func RemoveIngredientTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r)
|
||||
|
||||
// Extract ingredient ID and tag ID from path /ingredients/{ingredient_id}/tags/{tag_id}
|
||||
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/ingredients/"), "/")
|
||||
if len(pathParts) < 3 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ingredientID, err := strconv.Atoi(pathParts[0])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ingredient ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.Atoi(pathParts[2])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid tag ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove tag from ingredient
|
||||
if err := database.RemoveIngredientTag(userID, ingredientID, tagID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated tag list with trigger to refresh page
|
||||
w.Header().Set("HX-Trigger", "refreshIngredients")
|
||||
renderCurrentTags(w, userID, ingredientID)
|
||||
}
|
||||
|
||||
// Helper function to render current tags
|
||||
func renderCurrentTags(w http.ResponseWriter, userID, ingredientID int) {
|
||||
currentTags, err := database.GetIngredientTags(userID, ingredientID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := `
|
||||
{{range .Tags}}
|
||||
<span class="tag">
|
||||
{{.Name}}
|
||||
<button
|
||||
hx-delete="/ingredients/{{$.IngredientID}}/tags/{{.ID}}"
|
||||
hx-target="#current-tags-{{$.IngredientID}}"
|
||||
hx-swap="innerHTML"
|
||||
class="tag-remove">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
type TagWithID struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Tags []TagWithID
|
||||
IngredientID int
|
||||
}{
|
||||
Tags: []TagWithID{},
|
||||
IngredientID: ingredientID,
|
||||
}
|
||||
|
||||
for _, tag := range currentTags {
|
||||
data.Tags = append(data.Tags, TagWithID{ID: tag.ID, Name: tag.Name})
|
||||
}
|
||||
|
||||
t := template.Must(template.New("current-tags").Parse(tmpl))
|
||||
t.Execute(w, data)
|
||||
}
|
||||
|
||||
36
main.go
36
main.go
@@ -8,6 +8,7 @@ import (
|
||||
"mealprep/database"
|
||||
"mealprep/handlers"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,8 +16,14 @@ func main() {
|
||||
// Print startup banner
|
||||
printBanner()
|
||||
|
||||
// Get database path from environment variable or use default
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "mealprep.db"
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
if err := database.InitDB("mealprep.db"); err != nil {
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer database.DB.Close()
|
||||
@@ -48,6 +55,12 @@ func main() {
|
||||
})
|
||||
http.HandleFunc("/logout", handlers.LogoutHandler)
|
||||
|
||||
// Health check endpoint (public, for Docker)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
http.HandleFunc("/", auth.RequireAuth(indexHandler))
|
||||
|
||||
@@ -63,10 +76,25 @@ func main() {
|
||||
}
|
||||
}))
|
||||
http.HandleFunc("/ingredients/", auth.RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" {
|
||||
handlers.DeleteIngredientHandler(w, r)
|
||||
path := r.URL.Path
|
||||
if strings.Contains(path, "/tags") {
|
||||
// Ingredient tag routes
|
||||
if r.Method == "GET" {
|
||||
handlers.GetIngredientTagsHandler(w, r)
|
||||
} else if r.Method == "POST" {
|
||||
handlers.AddIngredientTagHandler(w, r)
|
||||
} else if r.Method == "DELETE" {
|
||||
handlers.RemoveIngredientTagHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
// Ingredient delete route
|
||||
if r.Method == "DELETE" {
|
||||
handlers.DeleteIngredientHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ type Ingredient struct {
|
||||
UserID int `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp"
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Tag represents a tag for categorizing ingredients
|
||||
type Tag struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// IngredientTag represents the many-to-many relationship between ingredients and tags
|
||||
type IngredientTag struct {
|
||||
IngredientID int `json:"ingredient_id"`
|
||||
TagID int `json:"tag_id"`
|
||||
}
|
||||
|
||||
// Meal represents a meal recipe
|
||||
|
||||
@@ -460,6 +460,201 @@ button:hover {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-tags-btn {
|
||||
background-color: #9b59b6;
|
||||
padding: 8px 15px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edit-tags-btn:hover {
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
|
||||
/* Tag Filter Section */
|
||||
.search-section {
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tag-filter-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #ecf0f1;
|
||||
color: #2c3e50;
|
||||
border: 2px solid #bdc3c7;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tag-filter-btn:hover {
|
||||
background-color: #bdc3c7;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
|
||||
.tag-filter-btn.active {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
min-height: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
/* Tag Edit Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal .modal-content {
|
||||
background-color: white;
|
||||
margin: 10% auto;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-edit-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tag-edit-section h4 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
#current-tags-{{.IngredientID}},
|
||||
[id^="current-tags-"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 40px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
padding: 0 5px;
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.add-tag-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.add-tag-form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-tag-form button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Authentication Pages */
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
|
||||
Reference in New Issue
Block a user