(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.
This commit is contained in:
Hayden Hargreaves 2025-06-29 22:30:20 -07:00
parent 695183bc99
commit b17c5774e9
18 changed files with 838 additions and 105 deletions

View File

@ -1 +1,5 @@
# Potion: Recipe Sharing Platform
## Todo List
- [-] Ingrident lists/sections

View File

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

View File

@ -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})
}

View File

@ -0,0 +1,75 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const TAG_HTML = `
<li
hx-post="/v1/web/state/tags/delete"
hx-trigger="click"
hx-target="#tag-list"
hx-swap="innerHTML"
hx-include="#tag-list"
hx-vals='{"target": "%s"}'
class="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300">
&times; %s
</li>
`
const TAG_LIST_HTML = `
<input
hx-swap-oob="outerHTML"
type="hidden"
name="tags"
id="tags"
value="%s"
/>
`
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)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package domain
type RecipeRepository interface {
// TODO: Not sure the input type yet
CreateRecipe(recipe *Recipe) error
}

View File

@ -0,0 +1,8 @@
package domain
import "github.com/gin-gonic/gin"
type RecipeService interface {
CreateRecipe(ctx *gin.Context) Recipe
}

View File

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

View File

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

View File

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

View File

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

View File

@ -56,6 +56,7 @@ templ FilterDropdown() {
Difficulty
</h3>
<div class="flex text-xs flex-wrap gap-1">
@dropdownButton("Beginner")
@dropdownButton("Easy")
@dropdownButton("Intermediate")
@dropdownButton("Challegening")

View File

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

View File

@ -3,5 +3,282 @@ package templates
import "github.com/haydenhargreaves/Potion/internal/templates/components"
templ CreatePage() {
@components.Navbar("create")
@components.Navbar("create")
<div class="w-full h-fit flex justify-center">
<div class="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 h-full border-l border-r border-gray-300 bg-white">
@Page()
</div>
</div>
}
templ Page() {
@components.BannerText("Create Your Masterpiece")
<p id="output"></p>
<div class="mx-4 md:mx-16 my-8">
<p class="mb-8">
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 <span class="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
button to
share your masterpiece!
</p>
<form>
<div class="flex flex-col">
<label for="title" class="text-sm mb-2">
Recipe Title
<span class="text-red-500">*</span>
</label>
<input
onkeydown="return event.key != 'Enter';"
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="text"
id="title"
name="title"
placeholder="e.g., Classic Chicken Curry"
/>
</div>
<div class="flex flex-col my-4">
<label for="description" class="text-sm mb-2">
Description
<span class="text-red-500">*</span>
</label>
<textarea
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all resize-none shadow-sm"
id="description"
name="description"
rows="4"
placeholder="A brief description of your delicious recipe..."
></textarea>
</div>
<div class="my-4 flex flex-col gap-x-2">
<div class="flex flex-col flex-grow">
<label for="tags" class="text-sm mb-2">
Recipe Tags
</label>
<input
onkeydown="return event.key != 'Enter';"
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
hx-post="/v1/web/state/tags"
maxlength="32"
hx-trigger="keyup[keyCode==13]"
hx-on::after-request="this.value=''"
hx-swap="innerHTML"
hx-target="#tag-list"
enterkeyhint="done"
type="text"
id="tag"
name="tag"
placeholder="e.g., Healthy"
/>
<input type="hidden" name="tags" id="tags" value=""/>
</div>
<ul id="tag-list" class="my-2 flex gap-1 flex-wrap"></ul>
</div>
<div class="my-4 flex gap-x-2">
<div class="flex flex-col flex-grow w-1/3">
<label for="preparation-time" class="text-sm mb-2">
Prep Time
<span class="text-red-500">*</span>
</label>
<input
onkeydown="return event.key != 'Enter';"
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="number"
id="preparation-time"
name="preparation-time"
placeholder="e.g., 20"
/>
</div>
<div class="flex flex-col flex-grow w-1/3">
<label for="cook-time" class="text-sm mb-2">
Cook Time
<span class="text-red-500">*</span>
</label>
<input
onkeydown="return event.key != 'Enter';"
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="number"
id="cook-time"
name="cook-time"
placeholder="e.g., 45"
/>
</div>
<div class="flex flex-col flex-grow w-1/3">
<label for="serving-size" class="text-sm mb-2">
Serving Size
<span class="text-red-500">*</span>
</label>
<input
onkeydown="return event.key != 'Enter';"
class="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="number"
max="16"
min="1"
id="serving-size"
name="serving-size"
placeholder="e.g., 4"
/>
</div>
</div>
<div class="my-4 flex gap-x-2">
<div class="flex flex-col flex-grow w-1/3">
<label for="category" class="text-sm mb-2">
Category
<span class="text-red-500">*</span>
</label>
<select
id="category"
name="category"
class="border border-gray-300 bg-gray-200 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
>
<option value="">Select a category</option>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
<option value="dessert">Dessert</option>
<option value="snack">Snack</option>
<option value="side">Side</option>
<option value="other">Other</option>
</select>
</div>
<div class="flex flex-col flex-grow w-1/3">
<label for="difficulty" class="text-sm mb-2">
Difficulty
<span class="text-red-500">*</span>
</label>
<select
id="difficulty"
name="difficulty"
class="border border-gray-300 bg-gray-200 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
>
<option value="">Select a difficulty</option>
<option value="1">Beginner</option>
<option value="2">Easy</option>
<option value="3">Intermediate</option>
<option value="4">Challenging</option>
<option value="5">Extreme</option>
</select>
</div>
</div>
<div class="flex flex-col my-4">
<label for="ingredients" class="text-sm">
Ingredients
<span class="text-red-500">*</span>
</label>
<div id="ingredient-list">
<div class="w-full flex gap-x-2 py-2">
<input
onkeydown="return event.key != 'Enter';"
class="flex-grow border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="text"
id="ingredients"
name="ingredients"
placeholder="Ingredient name (e.g., Chicken Breast)"
/>
<input
onkeydown="return event.key != 'Enter';"
class="w-1/3 border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="text"
id="quantity"
name="quantity"
placeholder="Quantity (e.g., 1lb)"
/>
</div>
</div>
<button
type="button"
onClick="addIngredient();"
class="text-base md:text-lg text-white bg-blue-500 w-fit px-5 py-2 rounded-lg cursor-pointer"
>
Add Ingredient
</button>
</div>
<div class="flex flex-col my-4">
<label for="instructions" class="text-sm">
Instructions
<span class="text-red-500">*</span>
</label>
<div id="instruction-list" class="flex flex-col">
<textarea
class="border border-gray-300 my-2 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all resize-none shadow-sm"
id="instructions"
name="instructions"
rows="3"
placeholder="Step 1: Describe this step..."
></textarea>
</div>
<button
type="button"
onClick="addInstruction();"
class="text-base md:text-lg text-white bg-blue-500 w-fit px-5 py-2 rounded-lg cursor-pointer"
>
Add Instruction Step
</button>
</div>
<div class="flex flex-col my-4">
<label for="image" class="text-sm">
Recipe Image
</label>
<input
type="file"
accept="image/*"
name="image"
id="image"
class="my-2 block w-full text-sm text-placeholder file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-blue-100 file:text-blue-700 cursor-pointer"
/>
</div>
<!-- <form hx-post="/v1/api/recipe" hx-swap="innerHTML" hx-target="#output" hx-trigger="submit"> -->
<button
type="submit"
hx-post="/v1/api/recipe"
hx-swap="innerHTML"
hx-target="#output"
hx-trigger="click"
hx-encoding="multipart/form-data"
class="w-full mt-8 bg-gradient-to-r from-blue-200 to-purple-200 py-2 rounded-lg text-lg cursor-pointer shadow-md"
>
Create Recipe
</button>
</form>
</div>
<script>
function addIngredient() {
const list = document.getElementById("ingredient-list");
const item = document.createElement("div");
item.classList.add("w-full", "flex", "gap-x-2", "py-2");
item.innerHTML = `
<input
onkeydown="return event.key != 'Enter';"
class="flex-grow border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="text"
id="ingredients"
name="ingredients"
placeholder="Ingredient name (e.g., Chicken Breast)"
/>
<input
onkeydown="return event.key != 'Enter';"
class="w-1/3 border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
type="text"
id="quantity"
name="quantity"
placeholder="Quantity (e.g., 1lb)"
/>
`;
list.appendChild(item);
}
function addInstruction() {
const list = document.getElementById("instruction-list");
const itemNum = list.children.length + 1;
const item = document.createElement("textarea");
item.id = "instructions";
item.name = "instructions";
item.className = "border border-gray-300 my-2 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all resize-none shadow-sm";
item.rows = "3";
item.placeholder = `Step ${itemNum}: Describe this step...`;
list.appendChild(item);
}
</script>
}

File diff suppressed because one or more lines are too long

View File

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