(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:
parent
695183bc99
commit
b17c5774e9
@ -1 +1,5 @@
|
|||||||
# Potion: Recipe Sharing Platform
|
# Potion: Recipe Sharing Platform
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [-] Ingrident lists/sections
|
||||||
|
|||||||
@ -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
|
data fields will also have a small example object. A more in-depth data structure can be
|
||||||
found in **OTHER** section.
|
found in **OTHER** section.
|
||||||
|
|
||||||
- [ ] Recipes: Represents a single recipe.
|
- [x] Recipes: Represents a single recipe.
|
||||||
- [ ] ID (PK) Serial
|
- [x] ID (PK) Serial
|
||||||
- [ ] Title (Unique, Required) string(128)
|
- [x] Title (Required) string(128)
|
||||||
- [ ] Description (Required) text
|
- [x] Description (Required) text
|
||||||
- [ ] Instructions (Required) string(1024)[]
|
- [x] Instructions (Required) string(1024)[]
|
||||||
- [ ] Serves (Required) int(0..16)
|
- [x] Serves (Required) int(0..16)
|
||||||
- [ ] Difficulty (Required) int(1..5)
|
- [x] Difficulty (Required) int(1..5)
|
||||||
- [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int })
|
- [x] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int })
|
||||||
- [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section)
|
- [x] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section)
|
||||||
- [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... })
|
- [x] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... })
|
||||||
- [ ] UserId (FK: User.Id) Serial
|
- [x] UserId (FK: User.Id) Serial
|
||||||
- [ ] Modified () date/time stamp
|
- [x] Modified () date/time stamp
|
||||||
- [ ] Created (Required) date/time stamp
|
- [x] Created (Required) date/time stamp
|
||||||
|
|
||||||
- [x] Users: Represents a single user.
|
- [x] Users: Represents a single user.
|
||||||
- [x] ID (PK) Serial
|
- [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.
|
Below is a breakdown of the required enumerated types that should be stored in the database.
|
||||||
Various tables will reference these types.
|
Various tables will reference these types.
|
||||||
|
|
||||||
- [ ] E_Meal: Type to represent the type of meal of a recipe.
|
- [x] E_Meal: Type to represent the type of meal of a recipe.
|
||||||
- [ ] breakfast: string
|
- [x] breakfast: string
|
||||||
- [ ] lunch: string
|
- [x] lunch: string
|
||||||
- [ ] dinner: string
|
- [x] dinner: string
|
||||||
- [ ] desert: string
|
- [x] dessert: string
|
||||||
- [ ] snack: string
|
- [x] snack: string
|
||||||
- [ ] side: string
|
- [x] side: string
|
||||||
- [ ] other: string
|
- [x] other: string
|
||||||
|
|
||||||
- [ ] E_Notification: Type to represent a type of user notification.
|
- [ ] E_Notification: Type to represent a type of user notification.
|
||||||
- [ ] comment: string
|
- [ ] comment: string
|
||||||
|
|||||||
49
internal/app/handlers/recipe_handler.go
Normal file
49
internal/app/handlers/recipe_handler.go
Normal 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})
|
||||||
|
}
|
||||||
75
internal/app/handlers/state_handler.go
Normal file
75
internal/app/handlers/state_handler.go
Normal 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">
|
||||||
|
× %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)
|
||||||
|
}
|
||||||
@ -113,12 +113,15 @@ func (s *Server) Setup() *Server {
|
|||||||
|
|
||||||
// Initialize and inject dependencies
|
// Initialize and inject dependencies
|
||||||
userRepo := repository.NewUserRepository(s.DB)
|
userRepo := repository.NewUserRepository(s.DB)
|
||||||
|
recipeRepo := repository.NewRecipeRepository(s.DB)
|
||||||
userService := service.NewUserService(userRepo)
|
userService := service.NewUserService(userRepo)
|
||||||
authService := service.NewAuthService(userRepo, jwtSecret)
|
authService := service.NewAuthService(userRepo, jwtSecret)
|
||||||
|
recipeService := service.NewRecipeService(recipeRepo)
|
||||||
|
|
||||||
deps := &domain.InjectedDependencies{
|
deps := &domain.InjectedDependencies{
|
||||||
UserService: userService,
|
UserService: userService,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
|
RecipeService: recipeService,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply middleware
|
// Apply middleware
|
||||||
@ -134,6 +137,7 @@ func (s *Server) Setup() *Server {
|
|||||||
// Domain specific routers
|
// Domain specific routers
|
||||||
router_web := router_v1.Group(domain.WEB)
|
router_web := router_v1.Group(domain.WEB)
|
||||||
router_api := router_v1.Group(domain.API)
|
router_api := router_v1.Group(domain.API)
|
||||||
|
router_state := router_web.Group("state")
|
||||||
|
|
||||||
// Static routes
|
// Static routes
|
||||||
router_web.Static("/static", "./web/static")
|
router_web.Static("/static", "./web/static")
|
||||||
@ -161,10 +165,18 @@ func (s *Server) Setup() *Server {
|
|||||||
router_web.GET("/profile", handlers.ProfilePage)
|
router_web.GET("/profile", handlers.ProfilePage)
|
||||||
router_web.GET("/list", handlers.ListPage)
|
router_web.GET("/list", handlers.ListPage)
|
||||||
|
|
||||||
|
// WEB state endpoints
|
||||||
|
router_state.POST("/tags", handlers.NewTag)
|
||||||
|
router_state.POST("/tags/delete", handlers.DeleteTag)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
router_api.GET("/auth/login", handlers.GoogleLogin)
|
router_api.GET("/auth/login", handlers.GoogleLogin)
|
||||||
router_api.GET("/auth/callback", handlers.GoogleCallback)
|
router_api.GET("/auth/callback", handlers.GoogleCallback)
|
||||||
router_api.GET("/auth/logout", handlers.Logout)
|
router_api.GET("/auth/logout", handlers.Logout)
|
||||||
|
|
||||||
|
// Recipe endpoints
|
||||||
|
// TODO: This should be post. Temp!
|
||||||
|
router_api.POST("/recipe", handlers.CreateRecipe)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
66
internal/app/service/recipe_service.go
Normal file
66
internal/app/service/recipe_service.go
Normal 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
|
||||||
|
}
|
||||||
49
internal/domain/recipe/recipe.go
Normal file
49
internal/domain/recipe/recipe.go
Normal 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
|
||||||
|
}
|
||||||
6
internal/domain/recipe/repository.go
Normal file
6
internal/domain/recipe/repository.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type RecipeRepository interface {
|
||||||
|
// TODO: Not sure the input type yet
|
||||||
|
CreateRecipe(recipe *Recipe) error
|
||||||
|
}
|
||||||
8
internal/domain/recipe/service.go
Normal file
8
internal/domain/recipe/service.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
type RecipeService interface {
|
||||||
|
CreateRecipe(ctx *gin.Context) Recipe
|
||||||
|
}
|
||||||
|
|
||||||
@ -4,12 +4,14 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
|
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
|
||||||
|
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||||
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InjectedDependencies struct {
|
type InjectedDependencies struct {
|
||||||
UserService domainUser.UserService
|
UserService domainUser.UserService
|
||||||
AuthService domainAuth.AuthService
|
AuthService domainAuth.AuthService
|
||||||
|
RecipeService domainRecipe.RecipeService
|
||||||
}
|
}
|
||||||
|
|
||||||
type JwtClaims struct {
|
type JwtClaims struct {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -56,6 +56,7 @@ templ FilterDropdown() {
|
|||||||
Difficulty
|
Difficulty
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex text-xs flex-wrap gap-1">
|
<div class="flex text-xs flex-wrap gap-1">
|
||||||
|
@dropdownButton("Beginner")
|
||||||
@dropdownButton("Easy")
|
@dropdownButton("Easy")
|
||||||
@dropdownButton("Intermediate")
|
@dropdownButton("Intermediate")
|
||||||
@dropdownButton("Challegening")
|
@dropdownButton("Challegening")
|
||||||
|
|||||||
@ -131,6 +131,10 @@ func FilterDropdown() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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)
|
templ_7745c5c3_Err = dropdownButton("Easy").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
|
|||||||
@ -4,4 +4,281 @@ import "github.com/haydenhargreaves/Potion/internal/templates/components"
|
|||||||
|
|
||||||
templ CreatePage() {
|
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
@ -8,8 +8,6 @@
|
|||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
--color-red-100: oklch(93.6% 0.032 17.717);
|
--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-red-500: oklch(63.7% 0.237 25.331);
|
||||||
--color-blue-100: oklch(93.2% 0.032 255.585);
|
--color-blue-100: oklch(93.2% 0.032 255.585);
|
||||||
--color-blue-200: oklch(88.2% 0.059 254.128);
|
--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-600: oklch(54.6% 0.245 262.881);
|
||||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
--color-purple-100: oklch(94.6% 0.033 307.174);
|
--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-50: oklch(98.5% 0.002 247.839);
|
||||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||||
@ -35,6 +34,8 @@
|
|||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--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: 1.125rem;
|
||||||
--text-lg--line-height: calc(1.75 / 1.125);
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
--text-xl: 1.25rem;
|
--text-xl: 1.25rem;
|
||||||
@ -205,9 +206,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.pointer-events-none {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.absolute {
|
.absolute {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
@ -217,9 +215,6 @@
|
|||||||
.static {
|
.static {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
.top-1 {
|
|
||||||
top: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1/2 * 100%);
|
top: calc(1/2 * 100%);
|
||||||
}
|
}
|
||||||
@ -229,9 +224,6 @@
|
|||||||
.left-0 {
|
.left-0 {
|
||||||
left: calc(var(--spacing) * 0);
|
left: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.left-1 {
|
|
||||||
left: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.left-1\/2 {
|
.left-1\/2 {
|
||||||
left: calc(1/2 * 100%);
|
left: calc(1/2 * 100%);
|
||||||
}
|
}
|
||||||
@ -250,9 +242,6 @@
|
|||||||
.mx-4 {
|
.mx-4 {
|
||||||
margin-inline: calc(var(--spacing) * 4);
|
margin-inline: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
.mx-8 {
|
|
||||||
margin-inline: calc(var(--spacing) * 8);
|
|
||||||
}
|
|
||||||
.my-2 {
|
.my-2 {
|
||||||
margin-block: calc(var(--spacing) * 2);
|
margin-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@ -262,9 +251,6 @@
|
|||||||
.my-8 {
|
.my-8 {
|
||||||
margin-block: calc(var(--spacing) * 8);
|
margin-block: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
.my-auto {
|
|
||||||
margin-block: auto;
|
|
||||||
}
|
|
||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@ -280,15 +266,18 @@
|
|||||||
.mt-16 {
|
.mt-16 {
|
||||||
margin-top: calc(var(--spacing) * 16);
|
margin-top: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
.mt-auto {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
.mb-1 {
|
.mb-1 {
|
||||||
margin-bottom: calc(var(--spacing) * 1);
|
margin-bottom: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
.mb-6 {
|
.mb-6 {
|
||||||
margin-bottom: calc(var(--spacing) * 6);
|
margin-bottom: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.mb-8 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.mb-10 {
|
.mb-10 {
|
||||||
margin-bottom: calc(var(--spacing) * 10);
|
margin-bottom: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@ -352,9 +341,6 @@
|
|||||||
.w-1\/3 {
|
.w-1\/3 {
|
||||||
width: calc(1/3 * 100%);
|
width: calc(1/3 * 100%);
|
||||||
}
|
}
|
||||||
.w-3 {
|
|
||||||
width: calc(var(--spacing) * 3);
|
|
||||||
}
|
|
||||||
.w-3\/4 {
|
.w-3\/4 {
|
||||||
width: calc(3/4 * 100%);
|
width: calc(3/4 * 100%);
|
||||||
}
|
}
|
||||||
@ -367,21 +353,12 @@
|
|||||||
.w-5 {
|
.w-5 {
|
||||||
width: calc(var(--spacing) * 5);
|
width: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
.w-9 {
|
|
||||||
width: calc(var(--spacing) * 9);
|
|
||||||
}
|
|
||||||
.w-9\/10 {
|
.w-9\/10 {
|
||||||
width: calc(9/10 * 100%);
|
width: calc(9/10 * 100%);
|
||||||
}
|
}
|
||||||
.w-24 {
|
.w-24 {
|
||||||
width: calc(var(--spacing) * 24);
|
width: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
.w-28 {
|
|
||||||
width: calc(var(--spacing) * 28);
|
|
||||||
}
|
|
||||||
.w-32 {
|
|
||||||
width: calc(var(--spacing) * 32);
|
|
||||||
}
|
|
||||||
.w-44 {
|
.w-44 {
|
||||||
width: calc(var(--spacing) * 44);
|
width: calc(var(--spacing) * 44);
|
||||||
}
|
}
|
||||||
@ -397,27 +374,16 @@
|
|||||||
.max-w-xl {
|
.max-w-xl {
|
||||||
max-width: var(--container-xl);
|
max-width: var(--container-xl);
|
||||||
}
|
}
|
||||||
.flex-shrink {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.border-collapse {
|
.flex-grow {
|
||||||
border-collapse: collapse;
|
flex-grow: 1;
|
||||||
}
|
|
||||||
.-translate-x-1 {
|
|
||||||
--tw-translate-x: calc(var(--spacing) * -1);
|
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
||||||
}
|
}
|
||||||
.-translate-x-1\/2 {
|
.-translate-x-1\/2 {
|
||||||
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
|
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
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 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@ -425,8 +391,8 @@
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.resize {
|
.resize-none {
|
||||||
resize: both;
|
resize: none;
|
||||||
}
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -458,6 +424,9 @@
|
|||||||
.gap-8 {
|
.gap-8 {
|
||||||
gap: calc(var(--spacing) * 8);
|
gap: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.gap-x-1 {
|
||||||
|
column-gap: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.gap-x-2 {
|
.gap-x-2 {
|
||||||
column-gap: calc(var(--spacing) * 2);
|
column-gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@ -467,9 +436,6 @@
|
|||||||
.gap-x-8 {
|
.gap-x-8 {
|
||||||
column-gap: calc(var(--spacing) * 8);
|
column-gap: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
.gap-x-16 {
|
|
||||||
column-gap: calc(var(--spacing) * 16);
|
|
||||||
}
|
|
||||||
.overflow-hidden {
|
.overflow-hidden {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -492,10 +458,6 @@
|
|||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
.border-1 {
|
|
||||||
border-style: var(--tw-border-style);
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
.border-2 {
|
.border-2 {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
@ -529,15 +491,18 @@
|
|||||||
.border-gray-300 {
|
.border-gray-300 {
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
}
|
}
|
||||||
.border-red-400 {
|
|
||||||
border-color: var(--color-red-400);
|
|
||||||
}
|
|
||||||
.border-red-500 {
|
.border-red-500 {
|
||||||
border-color: var(--color-red-500);
|
border-color: var(--color-red-500);
|
||||||
}
|
}
|
||||||
.border-white {
|
.border-white {
|
||||||
border-color: var(--color-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 {
|
.bg-gray-100 {
|
||||||
background-color: var(--color-gray-100);
|
background-color: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
@ -559,6 +524,10 @@
|
|||||||
--tw-gradient-from: var(--color-blue-100);
|
--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));
|
--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 {
|
.from-blue-400 {
|
||||||
--tw-gradient-from: var(--color-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));
|
--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-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));
|
--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 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
.p-8 {
|
|
||||||
padding: calc(var(--spacing) * 8);
|
|
||||||
}
|
|
||||||
.px-1 {
|
.px-1 {
|
||||||
padding-inline: calc(var(--spacing) * 1);
|
padding-inline: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@ -589,6 +559,9 @@
|
|||||||
.px-4 {
|
.px-4 {
|
||||||
padding-inline: calc(var(--spacing) * 4);
|
padding-inline: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.px-5 {
|
||||||
|
padding-inline: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.px-8 {
|
.px-8 {
|
||||||
padding-inline: calc(var(--spacing) * 8);
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@ -633,6 +606,10 @@
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
line-height: var(--tw-leading, var(--text-3xl--line-height));
|
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 {
|
.text-lg {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||||
@ -694,9 +671,6 @@
|
|||||||
.text-gray-800 {
|
.text-gray-800 {
|
||||||
color: var(--color-gray-800);
|
color: var(--color-gray-800);
|
||||||
}
|
}
|
||||||
.text-red-400 {
|
|
||||||
color: var(--color-red-400);
|
|
||||||
}
|
|
||||||
.text-red-500 {
|
.text-red-500 {
|
||||||
color: var(--color-red-500);
|
color: var(--color-red-500);
|
||||||
}
|
}
|
||||||
@ -706,9 +680,6 @@
|
|||||||
.uppercase {
|
.uppercase {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.underline {
|
|
||||||
text-decoration-line: underline;
|
|
||||||
}
|
|
||||||
.shadow {
|
.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));
|
--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);
|
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\] {
|
||||||
-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\:cursor-pointer {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:bg-gray-50 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-blue-400 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -930,6 +943,11 @@
|
|||||||
margin-inline: calc(var(--spacing) * 0);
|
margin-inline: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.md\:mx-16 {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
margin-inline: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
.md\:flex {
|
.md\:flex {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1026,12 +1044,6 @@
|
|||||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
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 {
|
.lg\:flex {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user