From b17c5774e9517df02cd3a1bfe9607e17eca175c5 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 29 Jun 2025 22:30:20 -0700 Subject: [PATCH] (UI): Implemented much of the frontend recipe creation wizard. Most everything is implemented, included a state handler and a pretty simple (but workable) system for managing state in HTML. Nice and simple for now. There is still much work to be done, but the rest is simple backend creation and error handling. And then input validation...a nightmare. --- README.md | 4 + doc/TechnicalSpecification.md | 42 +-- internal/app/handlers/recipe_handler.go | 49 +++ internal/app/handlers/state_handler.go | 75 +++++ internal/app/server/server.go | 16 +- internal/app/service/recipe_service.go | 66 +++++ internal/domain/recipe/recipe.go | 49 +++ internal/domain/recipe/repository.go | 6 + internal/domain/recipe/service.go | 8 + internal/domain/server/server.go | 6 +- .../migrations/002_create_meal_enum.sql | 17 ++ .../migrations/003_create_recipes_table.sql | 23 ++ .../database/repository/recipe_repository.go | 83 ++++++ internal/templates/components/dropdowns.templ | 1 + .../templates/components/dropdowns_templ.go | 4 + internal/templates/pages/create.templ | 279 +++++++++++++++++- internal/templates/pages/create_templ.go | 45 +++ web/static/css/tailwind.css | 170 ++++++----- 18 files changed, 838 insertions(+), 105 deletions(-) create mode 100644 internal/app/handlers/recipe_handler.go create mode 100644 internal/app/handlers/state_handler.go create mode 100644 internal/app/service/recipe_service.go create mode 100644 internal/domain/recipe/recipe.go create mode 100644 internal/domain/recipe/repository.go create mode 100644 internal/domain/recipe/service.go create mode 100644 internal/infrastructure/database/migrations/002_create_meal_enum.sql create mode 100644 internal/infrastructure/database/migrations/003_create_recipes_table.sql create mode 100644 internal/infrastructure/database/repository/recipe_repository.go diff --git a/README.md b/README.md index 346b7f8..afa141e 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ # Potion: Recipe Sharing Platform + +## Todo List + +- [-] Ingrident lists/sections diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index 0a2ed9e..bf3949b 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -239,19 +239,19 @@ also have a list of attributes which are to be implemented at the database level data fields will also have a small example object. A more in-depth data structure can be found in **OTHER** section. -- [ ] Recipes: Represents a single recipe. - - [ ] ID (PK) Serial - - [ ] Title (Unique, Required) string(128) - - [ ] Description (Required) text - - [ ] Instructions (Required) string(1024)[] - - [ ] Serves (Required) int(0..16) - - [ ] Difficulty (Required) int(1..5) - - [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int }) - - [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section) - - [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... }) - - [ ] UserId (FK: User.Id) Serial - - [ ] Modified () date/time stamp - - [ ] Created (Required) date/time stamp +- [x] Recipes: Represents a single recipe. + - [x] ID (PK) Serial + - [x] Title (Required) string(128) + - [x] Description (Required) text + - [x] Instructions (Required) string(1024)[] + - [x] Serves (Required) int(0..16) + - [x] Difficulty (Required) int(1..5) + - [x] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int }) + - [x] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section) + - [x] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... }) + - [x] UserId (FK: User.Id) Serial + - [x] Modified () date/time stamp + - [x] Created (Required) date/time stamp - [x] Users: Represents a single user. - [x] ID (PK) Serial @@ -325,14 +325,14 @@ found in **OTHER** section. Below is a breakdown of the required enumerated types that should be stored in the database. Various tables will reference these types. -- [ ] E_Meal: Type to represent the type of meal of a recipe. - - [ ] breakfast: string - - [ ] lunch: string - - [ ] dinner: string - - [ ] desert: string - - [ ] snack: string - - [ ] side: string - - [ ] other: string +- [x] E_Meal: Type to represent the type of meal of a recipe. + - [x] breakfast: string + - [x] lunch: string + - [x] dinner: string + - [x] dessert: string + - [x] snack: string + - [x] side: string + - [x] other: string - [ ] E_Notification: Type to represent a type of user notification. - [ ] comment: string diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go new file mode 100644 index 0000000..ee12ece --- /dev/null +++ b/internal/app/handlers/recipe_handler.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + // domain "github.com/haydenhargreaves/Potion/internal/domain/server" +) + +func CreateRecipe(ctx *gin.Context) { + // deps := ctx.MustGet("deps").(*domain.InjectedDependencies) + + title := ctx.PostForm("title") + description := ctx.PostForm("description") + preparation := ctx.PostForm("preparation-time") + cook := ctx.PostForm("cook-time") + serving := ctx.PostForm("serving-size") + category := ctx.PostForm("category") + difficulty := ctx.PostForm("difficulty") + ingredients := ctx.PostFormArray("ingredients") + quantity := ctx.PostFormArray("quantity") + instructions := ctx.PostFormArray("instructions") + tags := ctx.PostForm("tags") // this is a list of strings split with a comma (,) + + // Have to get the image differently + image, err := ctx.FormFile("image") + if err != nil { + ctx.JSON(http.StatusOK, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "title": title, + "description": description, + "cook time": cook, + "preparation time": preparation, + "serving size": serving, + "category": category, + "difficulty": difficulty, + "ingredients": ingredients, + "quantity": quantity, + "instructions": instructions, + "tags": tags, + "image": image.Filename, + }) + + // deps.RecipeService.CreateRecipe(ctx) + // ctx.JSON(http.StatusCreated, gin.H{"recipe": recipe}) +} diff --git a/internal/app/handlers/state_handler.go b/internal/app/handlers/state_handler.go new file mode 100644 index 0000000..274f864 --- /dev/null +++ b/internal/app/handlers/state_handler.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const TAG_HTML = ` +
  • + × %s +
  • +` + +const TAG_LIST_HTML = ` + +` + +func NewTag(ctx *gin.Context) { + tag := strings.ToLower(ctx.PostForm("tag")) + tags := strings.Split(ctx.PostForm("tags"), ",") + + tags = append([]string{tag}, tags...) + + var html string + var cleaned_tags []string + for _, tag := range tags { + if tag != "" { + html += fmt.Sprintf(TAG_HTML, tag, tag) + + // Ensure that the list provided does not contain blank spaces. + // This is another measure to ensure this state is bulletproof. + cleaned_tags = append(cleaned_tags, tag) + } + } + + // Execute OOB swap for the tags + html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(cleaned_tags, ",")) + + ctx.String(http.StatusOK, html) +} + +func DeleteTag(ctx *gin.Context) { + tags := strings.Split(ctx.PostForm("tags"), ",") + target := ctx.PostForm("target") + + var html string + var new_tags []string + for _, tag := range tags { + if tag != target && tag != "" { + html += fmt.Sprintf(TAG_HTML, tag, tag) + new_tags = append(new_tags, tag) + } + } + + // Execute OOB swap for the tags + html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(new_tags, ",")) + + ctx.String(http.StatusOK, html) +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 7f3e608..da6a782 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -113,12 +113,15 @@ func (s *Server) Setup() *Server { // Initialize and inject dependencies userRepo := repository.NewUserRepository(s.DB) + recipeRepo := repository.NewRecipeRepository(s.DB) userService := service.NewUserService(userRepo) authService := service.NewAuthService(userRepo, jwtSecret) + recipeService := service.NewRecipeService(recipeRepo) deps := &domain.InjectedDependencies{ - UserService: userService, - AuthService: authService, + UserService: userService, + AuthService: authService, + RecipeService: recipeService, } // Apply middleware @@ -134,6 +137,7 @@ func (s *Server) Setup() *Server { // Domain specific routers router_web := router_v1.Group(domain.WEB) router_api := router_v1.Group(domain.API) + router_state := router_web.Group("state") // Static routes router_web.Static("/static", "./web/static") @@ -161,10 +165,18 @@ func (s *Server) Setup() *Server { router_web.GET("/profile", handlers.ProfilePage) router_web.GET("/list", handlers.ListPage) + // WEB state endpoints + router_state.POST("/tags", handlers.NewTag) + router_state.POST("/tags/delete", handlers.DeleteTag) + // Authentication router_api.GET("/auth/login", handlers.GoogleLogin) router_api.GET("/auth/callback", handlers.GoogleCallback) router_api.GET("/auth/logout", handlers.Logout) + // Recipe endpoints + // TODO: This should be post. Temp! + router_api.POST("/recipe", handlers.CreateRecipe) + return s } diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go new file mode 100644 index 0000000..dcfa557 --- /dev/null +++ b/internal/app/service/recipe_service.go @@ -0,0 +1,66 @@ +package service + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" +) + +// RecipeService implements the domain.RecipeService defined in the domain module. +type RecipeService struct { + recipeRepository domain.RecipeRepository +} + +// Compile-time check to ensure the RecipeService implements domain.RecipeService +var _ domain.RecipeService = (*RecipeService)(nil) + +// NewRecipeService creates a user service object which can be passed into the context. The service +// requires a recipe repository which it will use to hit the database when needed. +func NewRecipeService(recipeRepository domain.RecipeRepository) domain.RecipeService { + return &RecipeService{recipeRepository: recipeRepository} +} + +func (s *RecipeService) CreateRecipe(ctx *gin.Context) domain.Recipe { + // TODO: Implement + + recipe := domain.Recipe{ + Title: "Delicious Go Curry", + Description: "A savory and easy-to-make curry, perfect for weeknights.", + Instructions: []string{ + "Chop all vegetables.", + "Sauté onions and garlic until fragrant.", + "Add curry paste and stir for 1 minute.", + "Add coconut milk and vegetables, simmer until cooked.", + "Serve with rice.", + }, + Serves: 4, + Difficulty: 3, + Duration: domain.RecipeDuration{ + Total: 45, + Prep: 15, + Cook: 30, + }, + Category: domain.MealDinner, // Using our EMeal type. Ensure this matches an updated enum value. + Ingredients: []domain.RecipeIngredient{ + {Name: "Onion", Quantity: "1 large"}, + {Name: "Garlic", Quantity: "3 cloves"}, + {Name: "Curry Paste", Quantity: "2 tbsp"}, + {Name: "Coconut Milk", Quantity: "400ml can"}, + {Name: "Broccoli", Quantity: "1 head"}, + {Name: "Bell Pepper", Quantity: "1 red"}, + {Name: "Rice", Quantity: "As needed"}, + }, + UserId: 3, + Created: time.Now(), + } + + if err := s.recipeRepository.CreateRecipe(&recipe); err != nil { + ctx.JSON(http.StatusOK, gin.H{"err": err.Error()}) + return domain.Recipe{} + } + + ctx.JSON(http.StatusCreated, gin.H{"recipe": recipe}) + return recipe +} diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go new file mode 100644 index 0000000..f05b36e --- /dev/null +++ b/internal/domain/recipe/recipe.go @@ -0,0 +1,49 @@ +package domain + +import "time" + +// RecipeDuration is the duration to prepare recipe. It has JSON tags which allows it to be +// marshaled into a JSON object and stored in the database (JSONB). +type RecipeDuration struct { + Total int `json:"total"` + Prep int `json:"prep"` + Cook int `json:"cook"` +} + +// RecipeMeal is the database enum E_MEAL which defines the meal type of a recipe. Postgres enums +// are case sensitive so these must match the values in the database exactly. +type RecipeMeal string + +const ( + MealBreakfast RecipeMeal = "breakfast" + MealLunch RecipeMeal = "lunch" + MealDinner RecipeMeal = "dinner" + MealDessert RecipeMeal = "dessert" + MealSnack RecipeMeal = "snack" + MealSide RecipeMeal = "side" + MealOther RecipeMeal = "other" +) + +// RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them +// to be marshaled into a JSON array and stored in the database (JSONB). +type RecipeIngredient struct { + Name string `json:"name"` + Quantity string `json:"quantity"` +} + +// Recipe is the database model of a recipe. There is no need to map to a different model so +// this will remain in the domain. +type Recipe struct { + Id int + Title string + Description string + Instructions []string + Serves int + Difficulty int + Duration RecipeDuration + Category RecipeMeal + Ingredients []RecipeIngredient // Just a list of ingredients + UserId int + Modified *time.Time // Pointer to allow null + Created time.Time +} diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go new file mode 100644 index 0000000..675dbb7 --- /dev/null +++ b/internal/domain/recipe/repository.go @@ -0,0 +1,6 @@ +package domain + +type RecipeRepository interface { + // TODO: Not sure the input type yet + CreateRecipe(recipe *Recipe) error +} diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go new file mode 100644 index 0000000..f11282e --- /dev/null +++ b/internal/domain/recipe/service.go @@ -0,0 +1,8 @@ +package domain + +import "github.com/gin-gonic/gin" + +type RecipeService interface { + CreateRecipe(ctx *gin.Context) Recipe +} + diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go index 8fce0f5..25c6b1d 100644 --- a/internal/domain/server/server.go +++ b/internal/domain/server/server.go @@ -4,12 +4,14 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" + domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" ) type InjectedDependencies struct { - UserService domainUser.UserService - AuthService domainAuth.AuthService + UserService domainUser.UserService + AuthService domainAuth.AuthService + RecipeService domainRecipe.RecipeService } type JwtClaims struct { diff --git a/internal/infrastructure/database/migrations/002_create_meal_enum.sql b/internal/infrastructure/database/migrations/002_create_meal_enum.sql new file mode 100644 index 0000000..096763e --- /dev/null +++ b/internal/infrastructure/database/migrations/002_create_meal_enum.sql @@ -0,0 +1,17 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Create the E_MEAL enum. +-- Date: 06/25/2025 + +BEGIN; + +CREATE TYPE E_MEAL AS ENUM( + 'breakfast', + 'lunch', + 'dinner', + 'dessert', + 'snack', + 'side', + 'other' +); + +COMMIT; diff --git a/internal/infrastructure/database/migrations/003_create_recipes_table.sql b/internal/infrastructure/database/migrations/003_create_recipes_table.sql new file mode 100644 index 0000000..051c971 --- /dev/null +++ b/internal/infrastructure/database/migrations/003_create_recipes_table.sql @@ -0,0 +1,23 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Create the recipes table in the database. +-- Date: 06/25/2025 + +BEGIN; + +-- Create the recipes table +CREATE TABLE IF NOT EXISTS Recipes ( + Id SERIAL PRIMARY KEY NOT NULL, + Title VARCHAR(128) NOT NULL, + Description TEXT NOT NULL, + Instructions VARCHAR(1024)[] NOT NULL, + Serves INTEGER NOT NULL CHECK (serves >= 0 AND serves <= 16), + Difficulty INTEGER NOT NULL CHECK (difficulty >= 1 AND difficulty <= 5), + Duration JSONB NOT NULL, + Category E_MEAL NOT NULL, + Ingredients JSONB NOT NULL, + UserId INTEGER NOT NULL REFERENCES users(id), + Modified TIMESTAMPTZ, + Created TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMIT; diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go new file mode 100644 index 0000000..4024a46 --- /dev/null +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -0,0 +1,83 @@ +package repository + +import ( + "database/sql" + "encoding/json" + + domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" + "github.com/lib/pq" +) + +type RecipeRepository struct { + db *sql.DB +} + +// Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository +var _ domain.RecipeRepository = (*RecipeRepository)(nil) + +// NewRecipeRepository creates a user repository object which is used by the user service to access +// the database. Any recipe related database operations will take place in this repository. +func NewRecipeRepository(db *sql.DB) domain.RecipeRepository { + return &RecipeRepository{db: db} +} + +// NOTE: This function modified the provided recipe with the new values, such as id and time stamp +func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return err + } + + query := `INSERT INTO recipes ( + title, description, instructions, serves, difficulty, + duration, category, ingredients, userid, modified, created + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + ) RETURNING id;` + + // NOTE: Data steps + // cast duration to JSON + // cast ingredients to JSON + // cast category to string + // use nil for the modified time + + durationJSON, err := json.Marshal(recipe.Duration) + if err != nil { + return err + } + + ingredientsJSON, err := json.Marshal(recipe.Ingredients) + if err != nil { + return err + } + + var id int + if err = tx.QueryRow( + query, + recipe.Title, + recipe.Description, + pq.Array(recipe.Instructions), + recipe.Serves, + recipe.Difficulty, + durationJSON, + string(recipe.Category), + ingredientsJSON, + recipe.UserId, + nil, + recipe.Created, + ).Scan(&id); err != nil { + tx.Rollback() + return err + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return err + } + + // Set the new ID + recipe.Id = id + + return nil +} diff --git a/internal/templates/components/dropdowns.templ b/internal/templates/components/dropdowns.templ index b74bd4f..6439394 100644 --- a/internal/templates/components/dropdowns.templ +++ b/internal/templates/components/dropdowns.templ @@ -56,6 +56,7 @@ templ FilterDropdown() { Difficulty
    + @dropdownButton("Beginner") @dropdownButton("Easy") @dropdownButton("Intermediate") @dropdownButton("Challegening") diff --git a/internal/templates/components/dropdowns_templ.go b/internal/templates/components/dropdowns_templ.go index 9a881d1..1a3d6e9 100644 --- a/internal/templates/components/dropdowns_templ.go +++ b/internal/templates/components/dropdowns_templ.go @@ -131,6 +131,10 @@ func FilterDropdown() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = dropdownButton("Beginner").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = dropdownButton("Easy").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/internal/templates/pages/create.templ b/internal/templates/pages/create.templ index 76e7974..73edb4b 100644 --- a/internal/templates/pages/create.templ +++ b/internal/templates/pages/create.templ @@ -3,5 +3,282 @@ package templates import "github.com/haydenhargreaves/Potion/internal/templates/components" templ CreatePage() { - @components.Navbar("create") + @components.Navbar("create") +
    +
    + @Page() +
    +
    +} + +templ Page() { + @components.BannerText("Create Your Masterpiece") +

    +
    +

    + Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation, + including the recipe's name, a description, and other specifics like its category, duration, + and difficulty. Don't forget to dynamically add all your ingredients and instructions using + the dedicated buttons, and feel free to upload an appealing image. All required fields are + marked with an *. Once everything looks perfect, just hit the "Create Recipe" + button to + share your masterpiece! +

    +
    +
    + + +
    +
    + + +
    +
    +
    + + + +
    +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      + + +
      +
      +
      + +
      +
      + + +
      +
      + +
      +
      + +
      + +
      + +
      +
      + + +
      + + +
      +
      + } diff --git a/internal/templates/pages/create_templ.go b/internal/templates/pages/create_templ.go index 711e8ac..fe16e17 100644 --- a/internal/templates/pages/create_templ.go +++ b/internal/templates/pages/create_templ.go @@ -35,6 +35,51 @@ func CreatePage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Page().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Page() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = components.BannerText("Create Your Masterpiece").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

      Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation, including the recipe's name, a description, and other specifics like its category, duration, and difficulty. Don't forget to dynamically add all your ingredients and instructions using the dedicated buttons, and feel free to upload an appealing image. All required fields are marked with an *. Once everything looks perfect, just hit the \"Create Recipe\" button to share your masterpiece!

        ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } return nil }) } diff --git a/web/static/css/tailwind.css b/web/static/css/tailwind.css index 3addad4..8c1bf62 100644 --- a/web/static/css/tailwind.css +++ b/web/static/css/tailwind.css @@ -8,8 +8,6 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --color-red-100: oklch(93.6% 0.032 17.717); - --color-red-200: oklch(88.5% 0.062 18.334); - --color-red-400: oklch(70.4% 0.191 22.216); --color-red-500: oklch(63.7% 0.237 25.331); --color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-200: oklch(88.2% 0.059 254.128); @@ -19,6 +17,7 @@ --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-200: oklch(90.2% 0.063 306.703); --color-gray-50: oklch(98.5% 0.002 247.839); --color-gray-100: oklch(96.7% 0.003 264.542); --color-gray-200: oklch(92.8% 0.006 264.531); @@ -35,6 +34,8 @@ --text-xs--line-height: calc(1 / 0.75); --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; @@ -205,9 +206,6 @@ } } @layer utilities { - .pointer-events-none { - pointer-events: none; - } .absolute { position: absolute; } @@ -217,9 +215,6 @@ .static { position: static; } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1/2 * 100%); } @@ -229,9 +224,6 @@ .left-0 { left: calc(var(--spacing) * 0); } - .left-1 { - left: calc(var(--spacing) * 1); - } .left-1\/2 { left: calc(1/2 * 100%); } @@ -250,9 +242,6 @@ .mx-4 { margin-inline: calc(var(--spacing) * 4); } - .mx-8 { - margin-inline: calc(var(--spacing) * 8); - } .my-2 { margin-block: calc(var(--spacing) * 2); } @@ -262,9 +251,6 @@ .my-8 { margin-block: calc(var(--spacing) * 8); } - .my-auto { - margin-block: auto; - } .mt-2 { margin-top: calc(var(--spacing) * 2); } @@ -280,15 +266,18 @@ .mt-16 { margin-top: calc(var(--spacing) * 16); } - .mt-auto { - margin-top: auto; - } .mb-1 { margin-bottom: calc(var(--spacing) * 1); } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } .mb-10 { margin-bottom: calc(var(--spacing) * 10); } @@ -352,9 +341,6 @@ .w-1\/3 { width: calc(1/3 * 100%); } - .w-3 { - width: calc(var(--spacing) * 3); - } .w-3\/4 { width: calc(3/4 * 100%); } @@ -367,21 +353,12 @@ .w-5 { width: calc(var(--spacing) * 5); } - .w-9 { - width: calc(var(--spacing) * 9); - } .w-9\/10 { width: calc(9/10 * 100%); } .w-24 { width: calc(var(--spacing) * 24); } - .w-28 { - width: calc(var(--spacing) * 28); - } - .w-32 { - width: calc(var(--spacing) * 32); - } .w-44 { width: calc(var(--spacing) * 44); } @@ -397,27 +374,16 @@ .max-w-xl { max-width: var(--container-xl); } - .flex-shrink { - flex-shrink: 1; - } .flex-shrink-0 { flex-shrink: 0; } - .border-collapse { - border-collapse: collapse; - } - .-translate-x-1 { - --tw-translate-x: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); + .flex-grow { + flex-grow: 1; } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -425,8 +391,8 @@ .cursor-pointer { cursor: pointer; } - .resize { - resize: both; + .resize-none { + resize: none; } .flex-col { flex-direction: column; @@ -458,6 +424,9 @@ .gap-8 { gap: calc(var(--spacing) * 8); } + .gap-x-1 { + column-gap: calc(var(--spacing) * 1); + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } @@ -467,9 +436,6 @@ .gap-x-8 { column-gap: calc(var(--spacing) * 8); } - .gap-x-16 { - column-gap: calc(var(--spacing) * 16); - } .overflow-hidden { overflow: hidden; } @@ -492,10 +458,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .border-1 { - border-style: var(--tw-border-style); - border-width: 1px; - } .border-2 { border-style: var(--tw-border-style); border-width: 2px; @@ -529,15 +491,18 @@ .border-gray-300 { border-color: var(--color-gray-300); } - .border-red-400 { - border-color: var(--color-red-400); - } .border-red-500 { border-color: var(--color-red-500); } .border-white { border-color: var(--color-white); } + .bg-blue-100 { + background-color: var(--color-blue-100); + } + .bg-blue-500 { + background-color: var(--color-blue-500); + } .bg-gray-100 { background-color: var(--color-gray-100); } @@ -559,6 +524,10 @@ --tw-gradient-from: var(--color-blue-100); --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); } + .from-blue-200 { + --tw-gradient-from: var(--color-blue-200); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } .from-blue-400 { --tw-gradient-from: var(--color-blue-400); --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); @@ -571,15 +540,16 @@ --tw-gradient-to: var(--color-purple-100); --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); } + .to-purple-200 { + --tw-gradient-to: var(--color-purple-200); + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } .p-2 { padding: calc(var(--spacing) * 2); } .p-4 { padding: calc(var(--spacing) * 4); } - .p-8 { - padding: calc(var(--spacing) * 8); - } .px-1 { padding-inline: calc(var(--spacing) * 1); } @@ -589,6 +559,9 @@ .px-4 { padding-inline: calc(var(--spacing) * 4); } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } .px-8 { padding-inline: calc(var(--spacing) * 8); } @@ -633,6 +606,10 @@ font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -694,9 +671,6 @@ .text-gray-800 { color: var(--color-gray-800); } - .text-red-400 { - color: var(--color-red-400); - } .text-red-500 { color: var(--color-red-500); } @@ -706,9 +680,6 @@ .uppercase { text-transform: uppercase; } - .underline { - text-decoration-line: underline; - } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -787,6 +758,48 @@ .\[-webkit-line-clamp\:4\] { -webkit-line-clamp: 4; } + .file\:mr-4 { + &::file-selector-button { + margin-right: calc(var(--spacing) * 4); + } + } + .file\:rounded-lg { + &::file-selector-button { + border-radius: var(--radius-lg); + } + } + .file\:border-0 { + &::file-selector-button { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .file\:bg-blue-100 { + &::file-selector-button { + background-color: var(--color-blue-100); + } + } + .file\:px-4 { + &::file-selector-button { + padding-inline: calc(var(--spacing) * 4); + } + } + .file\:py-2 { + &::file-selector-button { + padding-block: calc(var(--spacing) * 2); + } + } + .file\:text-sm { + &::file-selector-button { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } + .file\:text-blue-700 { + &::file-selector-button { + color: var(--color-blue-700); + } + } .hover\:cursor-pointer { &:hover { @media (hover: hover) { @@ -801,6 +814,13 @@ } } } + .hover\:bg-blue-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-200); + } + } + } .hover\:bg-gray-50 { &:hover { @media (hover: hover) { @@ -815,13 +835,6 @@ } } } - .hover\:bg-red-200 { - &:hover { - @media (hover: hover) { - background-color: var(--color-red-200); - } - } - } .hover\:text-blue-400 { &:hover { @media (hover: hover) { @@ -930,6 +943,11 @@ margin-inline: calc(var(--spacing) * 0); } } + .md\:mx-16 { + @media (width >= 48rem) { + margin-inline: calc(var(--spacing) * 16); + } + } .md\:flex { @media (width >= 48rem) { display: flex; @@ -1026,12 +1044,6 @@ line-height: var(--tw-leading, var(--text-sm--line-height)); } } - .md\:text-xl { - @media (width >= 48rem) { - font-size: var(--text-xl); - line-height: var(--tw-leading, var(--text-xl--line-height)); - } - } .lg\:flex { @media (width >= 64rem) { display: flex;