Part 3 - Nuxnuxx/showcase_go GitHub Wiki
For a goodlooking website we need a navbar, footer those things are called partials
.
So start by creating a folder call partials
in the views folder in which we add a file call navbar.partial.templ
.
EXERCICE
This navbar should be able to go to page /
,/list
,profil
and /liked
which are self explanatory.
Enjoy and try do something cool, then implement it to the layout
CORRECTION
//Filename: internal/views/partials/navbar.partial.templ
templ NavBar(){
<nav class="navbar bg-primary text-primary-content fixed top-0 z-10">
<div class="navbar-start">
<a hx-swap="transition:true" class="btn btn-ghost text-xl" href="/">
Todo List
</a>
</div>
<div class="navbar-end">
<a class="btn btn-ghost text-lg" href="/">
Home
</a>
<a class="btn btn-ghost text-lg" href="/list">
List
</a>
<a class="btn btn-ghost text-lg" href="/liked">
Liked
</a>
<a class="btn btn-ghost text-lg" href="/profil">
Profil
</a>
</div>
</nav>
}
//Filename: internal/views/layout/base.layout.templ
<body>
<header>
@partials.NavBar()
</header>
{ children... }
</body>
Ok now that you have played a little with the templating engine, don't you find annoying to need to rerun templ generate
everytime you make a change, let's fix this go in your Makefile
build:
@templ generate
Now it will recreate template at every rerun of make run
You can also make a footer if you want !
For now all of this is just frontend stuff and no ones of those link are working, let's get on it
First the home page we need a page that informs the user of what is the website
Create a new file in the layout
folder call homepage.layout.templ
and insert your homepage templ
//Filename: internal/views/layout/homepage.layout.templ
templ Home() {
<div class="container mx-auto mt-8">
<section class="text-center">
<h1 class="text-4xl font-bold text-gray-800 mb-4">Welcome to GameApp!</h1>
<p class="text-lg text-gray-600 mb-8">Discover and like your favorite games.</p>
<a href="/login" class="bg-purple-500 text-white px-6 py-3 rounded-md text-lg hover:bg-blue-600">Get Started</a>
</section>
</div>
}
templ HomeIndex() {
@Base() {
@Home()
}
}
Then add a new endpoint in routes.go
and modify the current one
//Filename: internal/handlers/routes.go
func SetupRoutes(e *echo.Echo, gh *GamesHandler) {
e.GET("/", HomeHandler)
e.GET("/list", gh.GetGamesByPage)
}
func HomeHandler(c echo.Context) error {
return renderView(c, layout.HomeIndex())
}
Here we have modify the endpoint to get the game of list on the path /list
and create a new handler in which we render our new views
The list endpoint don't work anymore because if we don't pass a query parameters page=x
it crash and say invalid page why ?
Because of this part
//Filename: internal/handlers/games.handlers.go
func (gh *GamesHandler) GetGamesByPage(c echo.Context) error {
page, err := strconv.Atoi(c.QueryParam("page"))
// This part just return JSON
if err != nil {
return c.JSON(http.StatusBadRequest, "Invalid page")
}
games, err := gh.GamesServices.GetGamesByPage(page)
// This is also handle with a return as a JSON
if err != nil {
return c.JSON(http.StatusInternalServerError, "Something went wrong")
}
return renderView(c, gamesviews.GameIndex(games))
}
So we need a way to show error in a more pretty / web app flavor, one of the common way to do it is to create a view for each error or redirect to another page.
We could also just change the behavior of this handler to just send the page 0 if it had receive no page on his parameters.
Finally, we will do both, first change the function to not crash when there is no query params.
page := c.QueryParam("page")
if page == "" {
page = "0"
}
pageInt, err := strconv.Atoi(page)
if err != nil {
return c.JSON(http.StatusBadRequest, "Invalid page")
}
Now the start of the handler should look like this, but we are still handling error with JSON response, let's fix it.
Create a new folders in views
named errors_pages
and add one named error.400.templ
in it we will show a more looking good errors
//Filename: internal/views/errors_page/error.400.templ
templ Error400() {
<section class="flex flex-col items-center justify-center h-[100vh] gap-4">
<div class="items-center justify-center flex flex-col gap-4">
<h1 class="text-9xl font-extrabold text-gray-700 tracking-widest">
400
</h1>
<h2 class="bg-rose-700 px-2 text-sm rounded rotate-[20deg] absolute">
Bad Request
</h2>
</div>
<p class="text-xs text-center md:text-sm text-gray-400">
Your request was malformed
</p>
</section>
}
templ Error400Index(){
@layout.Base(){
@Error400()
}
}
This should looks good, now we can use it in the handler
//Filename: internal/handlers/games.handlers.go
func (gh *GamesHandler) GetGamesByPage(c echo.Context) error {
page := c.QueryParam("page")
if page == "" {
page = "0"
}
pageInt, err := strconv.Atoi(page)
if err != nil {
return renderView(c, errors_pages.Error400Index())
}
games, err := gh.GamesServices.GetGamesByPage(pageInt)
if err != nil {
return c.JSON(http.StatusInternalServerError, "Something went wrong")
}
return renderView(c, gamesviews.GameIndex(games))
}
EXERCICE You can do the same for 500 now.
Next we should redesign this horrible list page.
//Filename: internal/views/games_views/game.list.templ
templ GameCard(game services.Game){
<div class="card">
<figure><img class="object-cover max-h-60 max-w-full" src={game.BackgroundImage} alt={game.Name} /></figure>
<div class="card-body">
<h2 class="card-title">{game.Name}</h2>
<p>{game.Released}</p>
</div>
</div>
}
templ GamesList(games []services.Game){
<div class="grid grid-cols-3 gap-4">
// Loop through games
for _, game := range games{
<div class="col">
@GameCard(game)
</div>
}
</div>
}
templ GameIndex(games []services.Game){
@layout.Base(){
<h1>Games</h1>
@GamesList(games)
}
}
Now that it looks fine, let's focus on a really cool features infinite scroll
, here HTMX come to play
Here is an example of a infinite scroll in HTMX, look pretty simple isn't it.
First let's install htmx, add this to the header
of the layout template.
//Filename: internal/views/layout/base.layout.templ
<script src="https://unpkg.com/[email protected]" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
First we need to pass the currentPage from the backend to the frontend by changing the handler
//Filename: internal/handlers/games.handlers.go
return renderView(c, gamesviews.GameIndex(games)) -> return renderView(c, gamesviews.GameIndex(games, pageInt))
Then modifying
//Filename: internal/views/games_views/game.list.templ
templ GamesList(games []services.Game, currentPage int) {
<div id="game_list" class="grid grid-cols-3 gap-4">
// Loop through games
for i, game := range games {
if i == len(games) - 1 {
<div
id="load_more"
hx-trigger="revealed"
hx-get={ "/list?page=" + strconv.Itoa(currentPage+1) }
>
@GameCard(game)
</div>
}
@GameCard(game)
}
</div>
}
templ GameIndex(games []services.Game, currentPage int) {
@layout.Base() {
@GamesList(games, currentPage)
}
}
But what happens ! Our result has been in one card only but why ?
Because by default HTMX replace the element from which you make the request we need to specify which target and how we want to swap them.
Also it will bug because it also send the <div id="game_list" class="grid grid-cols-3 gap-4">
around it for each request we need to move it to the index
We also need to send a new GameList when the page is more then 0.
Which give us this
//Filename: internal/views/games_views/game.list.templ
templ GamesList(games []services.Game, currentPage int) {
// Loop through games
for i, game := range games {
if i == len(games) - 1 {
<div id="load_more" hx-trigger="revealed" hx-get={ "/list?page=" + strconv.Itoa(currentPage+1) } hx-target="#game_list" hx-swap="beforeend">
@GameCard(game)
</div>
}
@GameCard(game)
}
}
templ GameIndex(games []services.Game, currentPage int) {
@layout.Base() {
<div id="game_list" class="grid grid-cols-4 gap-4">
@GamesList(games, currentPage)
</div>
}
}
Add it at the end of the end of the handlers before the first render return.
//Filename: internal/handlers/games.handlers.go
if pageInt > 0 {
return renderView(c, gamesviews.GamesList(games, pageInt))
}
Also we can add hx-boost="once"
so it only happens once and we can scroll back without causing a new request.
And boom we got infinite scroll with few lines.
Now that we are here we can also put hx-boost="true
to the body so the anchor tag are now doing ajax request and not a full reload of the web app.
It would be cool not to have the need to run make run at every changes, that when air comes along, with this binary we can define a config file in which it can run every command we want after certain files has been changed.
First install air by going on their github.
Run air init
, you should have now a .air.toml
created.
Let's modify the config files, first add a new extension files to watch one
include_ext = ["go", "tpl", "tmpl", "html", "templ"]
And modify the bin and command to run on every reload
bin = "./bin/app"
cmd = "make build"
Now we are fine if you run air
, it should restart at every changes.
First we need to make a new function in our services for the game, let's create a function to retrieve a game by id.
//Filename: internal/services/games.services.go
func (gs *GameService) GetGamesByID(id int) (GameFullDetail, error){
// Make the url
builder := strings.Builder{}
builder.WriteString("https://api.rawg.io/api/games/")
builder.WriteString(strconv.Itoa(id))
builder.WriteString("?key=")
builder.WriteString(gs.ApiKey)
resp, err := http.Get(builder.String())
if err != nil {
return GameFullDetail{}, fmt.Errorf("Error making request: %v", err)
}
defer resp.Body.Close()
// This part bind the response to the struct
var response GameFullDetail
body, err := io.ReadAll(resp.Body)
if err := json.Unmarshal(body, &response); err != nil {
return GameFullDetail{}, fmt.Errorf("Error unmarshalling response: %v", err)
}
return response, nil
}
We can't return a nil this time because the type Game
is not a pointer such as below ere it was a array.
And a new type which we will be bind to it
//Filename: internal/services/games.services.go
type GameFullDetail struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
NameOriginal string `json:"name_original"`
Description string `json:"description"`
Metacritic int `json:"metacritic"`
MetacriticPlatforms []MetacriticPlatform `json:"metacritic_platforms"`
Released string `json:"released"`
TBA bool `json:"tba"`
Updated string `json:"updated"`
BackgroundImage string `json:"background_image"`
BackgroundImageAdditional string `json:"background_image_additional"`
Website string `json:"website"`
Rating float32 `json:"rating"`
RatingTop int `json:"rating_top"`
Reactions map[string]interface{} `json:"reactions"`
Added int `json:"added"`
AddedByStatus map[string]interface{} `json:"added_by_status"`
Playtime int `json:"playtime"`
ScreenshotsCount int `json:"screenshots_count"`
MoviesCount int `json:"movies_count"`
CreatorsCount int `json:"creators_count"`
AchievementsCount int `json:"achievements_count"`
ParentAchievementsCount int `json:"parent_achievements_count"`
RedditURL string `json:"reddit_url"`
RedditName string `json:"reddit_name"`
RedditDescription string `json:"reddit_description"`
RedditLogo string `json:"reddit_logo"`
RedditCount int `json:"reddit_count"`
TwitchCount int `json:"twitch_count"`
YoutubeCount int `json:"youtube_count"`
ReviewsTextCount int `json:"reviews_text_count"`
RatingsCount int `json:"ratings_count"`
SuggestionsCount int `json:"suggestions_count"`
AlternativeNames []string `json:"alternative_names"`
MetacriticURL string `json:"metacritic_url"`
ParentsCount int `json:"parents_count"`
AdditionsCount int `json:"additions_count"`
GameSeriesCount int `json:"game_series_count"`
ESRBRating EsrbRating `json:"esrb_rating"`
Platforms []Platform `json:"platforms"`
}
After that we can create a handler to serve this page.
//Filename: internal/handlers/games.handlers.go
func (gh *GamesHandler) GetGameById(c echo.Context) error {
id := c.Param("id")
idInt, err := strconv.Atoi(id)
if err != nil {
return renderView(c, errors_pages.Error400Index())
}
game, err := gh.GamesServices.GetGamesByID(idInt)
if err != nil {
fmt.Println(err)
return renderView(c, errors_pages.Error500Index())
}
return renderView(c, gamesviews.GamePageIndex(game))
}
Don't forget to add our new function to the interface at the top of the file.
Add a view to show the information we just retrieve
//Filename: internal/views/games_views/game.list.templ
templ GamePage(game services.GameFullDetail){
<div class="max-w-4xl mx-auto shadow-md rounded-md p-6">
<h1 class="text-3xl font-bold mb-4">{game.Name}</h1>
<img src={game.BackgroundImage} alt={game.Name} class="w-full h-auto rounded-md mb-4"/>
<h2 class="text-2xl font-bold mb-2">{game.Name}</h2>
<p class="text-lg mb-4">Released: {game.Released}</p>
<p class="text-base mb-4">{game.Description}</p>
if (game.Website != ""){
<a target="_blank" href={templ.URL(game.Website)} class="btn">{game.Website}</a>
}
</div>
}
templ GamePageIndex(game services.GameFullDetail){
@layout.Base(){
@GamePage(game)
}
}
And what we need to do now ?
Yes add the it to the routes
//Filename: internal/handlers/routes.go
e.GET("/game/:id", gh.GetGameById)