(FEAT): Errors can now be displayed using the RenderErrorBanner fx.

This is found in the components domain. Make sure only HTMX routes call
it. Although, I think I put this into the page handlers WHICH IS WRONG.

However, it does work, ish. But it does not load into the DOM properly.
But it seems to display just fine.
This commit is contained in:
Hayden Hargreaves 2025-09-03 22:22:23 -07:00
parent 05ecea56db
commit 47b8386844
7 changed files with 270 additions and 27 deletions

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
)
func EngagementViewRecipe(ctx *gin.Context) {
@ -101,6 +102,7 @@ func EngagementFavoriteRecipe(ctx *gin.Context) {
recipeId, _ := strconv.Atoi(id)
if _, err := deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Something went wrong. %s.", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),

View File

@ -11,6 +11,7 @@ import (
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts"
pages "github.com/haydenhargreaves/Potion/internal/templates/pages"
templates "github.com/haydenhargreaves/Potion/internal/templates/pages"
@ -41,6 +42,7 @@ func HomePage(ctx *gin.Context) {
userId := ctx.MustGet("userId").(int)
madeRecipes, err := deps.RecipeService.GetUserMadeRecipes(userId, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting made recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
@ -49,9 +51,10 @@ func HomePage(ctx *gin.Context) {
}
viewedRecipes, err := deps.RecipeService.GetUserViewedRecipes(userId, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
"message": fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()),
})
return
}
@ -59,9 +62,10 @@ func HomePage(ctx *gin.Context) {
// Get the recipe of the week
recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(&userId)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
"message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
})
return
}
@ -82,9 +86,10 @@ func HomePage(ctx *gin.Context) {
// Get the recipe of the week
recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(nil)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
"message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
})
return
}
@ -178,6 +183,7 @@ func ProfilePage(ctx *gin.Context) {
recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
@ -187,9 +193,10 @@ func ProfilePage(ctx *gin.Context) {
favorites, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
"message": fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()),
})
return
}
@ -197,6 +204,7 @@ func ProfilePage(ctx *gin.Context) {
// Get the engagement data, not sure what will happen when errors occur
engagements, err := deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting user engagements. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
@ -217,7 +225,6 @@ func ListPage(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
// TODO: Figure out how to handle errors, think we just need a simple display.
func RecipePage(ctx *gin.Context) {
// Call recipe service to get via ID
deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
@ -226,7 +233,7 @@ func RecipePage(ctx *gin.Context) {
// Parse ID
parsed, err := strconv.Atoi(id)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}
@ -251,7 +258,7 @@ func RecipePage(ctx *gin.Context) {
// Get recipe
recipe, err := deps.RecipeService.GetRecipe(parsed, userId)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}
@ -259,7 +266,7 @@ func RecipePage(ctx *gin.Context) {
// Get user (owner)
user, err := deps.UserService.GetUser(recipe.UserId)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}

View File

@ -0,0 +1,58 @@
package components
import "github.com/gin-gonic/gin"
templ errorIcon() {
<svg class="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 16.99V17M12 7V14M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
}
templ closeButton() {
<button
onclick="hideError();"
class="text-red-500 ml-auto hover:bg-red-200 p-1 rounded-sm transition-all duration-300 cursor-pointer"
>
<svg class="h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.207 6.207a1 1 0 0 0-1.414-1.414L12 10.586 6.207 4.793a1 1 0 0 0-1.414 1.414L10.586 12l-5.793 5.793a1 1 0 1 0 1.414 1.414L12 13.414l5.793 5.793a1 1 0 0 0 1.414-1.414L13.414 12l5.793-5.793z"
fill="currentColor"
></path>
</svg>
</button>
}
templ errorBanner(message string) {
<div
id="error-toast"
hx-swap-oob="outerHTML"
class="fixed z-20 border border-red-500 rounded-sm right-0 top-0 m-4 p-4 bg-red-100 shadow shadow-red-200 transition-all duration-300 text-sm md:w-1/3"
>
<div class="flex items-center gap-x-2 pb-1">
@errorIcon()
<h1 class="text-red-500 font-semibold">Error</h1>
@closeButton()
</div>
<div class="ml-8">
<p class="text-red-500 text-sm">
{ message }
</p>
</div>
</div>
}
// RenderErrorBanner renders the error banner. However, this function must ONLY be called by an
// HTMX route. Otherwise, there is no promise it will work (may result in undefined behavior).
// Just writes a piece of content to the response.
func RenderErrorBanner(ctx *gin.Context, message string) {
ctx.Writer.Header().Set("Content-Type", "text/html")
errorBanner(message).Render(ctx.Request.Context(), ctx.Writer)
}

View File

@ -0,0 +1,137 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.937
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/gin-gonic/gin"
func errorIcon() 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"h-6 text-red-500\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12 16.99V17M12 7V14M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func closeButton() 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<button onclick=\"hideError();\" class=\"text-red-500 ml-auto hover:bg-red-200 p-1 rounded-sm transition-all duration-300 cursor-pointer\"><svg class=\"h-4\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M19.207 6.207a1 1 0 0 0-1.414-1.414L12 10.586 6.207 4.793a1 1 0 0 0-1.414 1.414L10.586 12l-5.793 5.793a1 1 0 1 0 1.414 1.414L12 13.414l5.793 5.793a1 1 0 0 0 1.414-1.414L13.414 12l5.793-5.793z\" fill=\"currentColor\"></path></svg></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func errorBanner(message string) 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_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div id=\"error-toast\" hx-swap-oob=\"outerHTML\" class=\"fixed z-20 border border-red-500 rounded-sm right-0 top-0 m-4 p-4 bg-red-100 shadow shadow-red-200 transition-all duration-300 text-sm md:w-1/3\"><div class=\"flex items-center gap-x-2 pb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = errorIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h1 class=\"text-red-500 font-semibold\">Error</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = closeButton().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"ml-8\"><p class=\"text-red-500 text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/error.templ`, Line: 46, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RenderErrorBanner renders the error banner. However, this function must ONLY be called by an
// HTMX route. Otherwise, there is no promise it will work (may result in undefined behavior).
// Just writes a piece of content to the response.
func RenderErrorBanner(ctx *gin.Context, message string) {
ctx.Writer.Header().Set("Content-Type", "text/html")
errorBanner(message).Render(ctx.Request.Context(), ctx.Writer)
}
var _ = templruntime.GeneratedTemplate

View File

@ -3,20 +3,24 @@ package templates
// AppLayout is the main application layout, this does not contain any content other than
// meta data, links, scripts and whatever is passed into it as a component.
templ AppLayout(title string, child templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{ title }</title>
<link rel="stylesheet" href="/v1/web/static/css/tailwind.css" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body class="bg-gray-100">
@child
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link rel="stylesheet" href="/v1/web/static/css/tailwind.css"/>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body class="bg-gray-100">
<div id="error-toast"></div>
@child
<script>
function hideError() {
const err = document.getElementById("error-toast");
err.classList.add("hidden");
}
</script>
</body>
</html>
}

View File

@ -38,13 +38,13 @@ func AppLayout(title string, child templ.Component) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 12, Col: 16}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 11, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body class=\"bg-gray-100\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body class=\"bg-gray-100\"><div id=\"error-toast\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -52,7 +52,7 @@ func AppLayout(title string, child templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script>\n function hideError() {\n const err = document.getElementById(\"error-toast\");\n err.classList.add(\"hidden\");\n }\n </script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -8,6 +8,7 @@
--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-500: oklch(63.7% 0.237 25.331);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-blue-50: oklch(97% 0.014 254.604);
@ -235,18 +236,27 @@
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.relative {
position: relative;
}
.static {
position: static;
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1\/2 {
top: calc(1/2 * 100%);
}
.top-\[100\%\] {
top: 100%;
}
.right-0 {
right: calc(var(--spacing) * 0);
}
.left-0 {
left: calc(var(--spacing) * 0);
}
@ -262,6 +272,9 @@
.z-20 {
z-index: 20;
}
.m-4 {
margin: calc(var(--spacing) * 4);
}
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
@ -337,6 +350,12 @@
.mb-16 {
margin-bottom: calc(var(--spacing) * 16);
}
.ml-8 {
margin-left: calc(var(--spacing) * 8);
}
.ml-auto {
margin-left: auto;
}
.block {
display: block;
}
@ -694,6 +713,9 @@
--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-1 {
padding: calc(var(--spacing) * 1);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
@ -939,6 +961,12 @@
--tw-shadow-color: color-mix(in oklab, var(--color-gray-300) var(--tw-shadow-alpha), transparent);
}
}
.shadow-red-200 {
--tw-shadow-color: oklch(88.5% 0.062 18.334);
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow-color: color-mix(in oklab, var(--color-red-200) var(--tw-shadow-alpha), transparent);
}
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
@ -1141,6 +1169,13 @@
}
}
}
.hover\:bg-red-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-red-200);
}
}
}
.hover\:text-blue-400 {
&:hover {
@media (hover: hover) {