├── .gitignore ├── doc ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png ├── screenshot-4.png ├── screenshot-5.png └── screenshot-6.png ├── go.mod ├── .air.toml ├── views ├── error_404.templ ├── error_500.templ ├── home.templ ├── partials │ ├── flashmessages_partial.templ │ └── navbar_partial.templ ├── todo_views │ ├── create.templ │ ├── update.templ │ └── todo.list.templ ├── base_layout.templ └── auth_views │ ├── login.templ │ └── register.templ ├── assets ├── css │ └── styles.css └── img │ └── gopher-svgrepo-com.svg ├── LICENSE ├── models ├── db.go ├── user_model.go └── todo_model.go ├── main.go ├── handlers ├── routes.go ├── auth_handlers.go └── todo_handlers.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | bin 3 | 4 | app_data.db 5 | **/*_templ.go -------------------------------------------------------------------------------- /doc/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-1.png -------------------------------------------------------------------------------- /doc/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-2.png -------------------------------------------------------------------------------- /doc/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-3.png -------------------------------------------------------------------------------- /doc/screenshot-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-4.png -------------------------------------------------------------------------------- /doc/screenshot-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-5.png -------------------------------------------------------------------------------- /doc/screenshot-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/gofiber-templ-htmx/HEAD/doc/screenshot-6.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emarifer/gofiber-templ-htmx 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/a-h/templ v0.2.747 7 | github.com/gofiber/fiber/v2 v2.51.0 8 | ) 9 | 10 | require ( 11 | github.com/andybalholm/brotli v1.1.0 // indirect 12 | github.com/google/uuid v1.4.0 // indirect 13 | github.com/klauspost/compress v1.17.9 // indirect 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | github.com/mattn/go-isatty v0.0.20 // indirect 16 | github.com/mattn/go-runewidth v0.0.15 // indirect 17 | github.com/mattn/go-sqlite3 v1.14.18 18 | github.com/rivo/uniseg v0.2.0 // indirect 19 | github.com/sujit-baniya/flash v0.1.8 20 | github.com/valyala/bytebufferpool v1.0.0 // indirect 21 | github.com/valyala/fasthttp v1.55.0 // indirect 22 | github.com/valyala/tcplisten v1.0.0 // indirect 23 | golang.org/x/crypto v0.24.0 24 | golang.org/x/sys v0.21.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "templ generate && go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "templ"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | rerun = false 24 | rerun_delay = 500 25 | send_interrupt = false 26 | stop_on_error = false 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true 45 | -------------------------------------------------------------------------------- /views/error_404.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ Error404(fromProtected bool) { 4 |
5 |
6 |

7 | 404 8 |

9 |

10 | Resource not found 11 |

12 |
13 |

14 | The requested resource could not be found but may be available again in the future. 15 |

16 | if !fromProtected { 17 | 18 | Go Home Page 19 | 20 | } else { 21 | 26 | Go Todo List Page 27 | 28 | } 29 |
30 | } 31 | -------------------------------------------------------------------------------- /assets/css/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Kanit&display=swap'); 2 | 3 | body { 4 | font-family: "Kanit", sans-serif; 5 | } 6 | 7 | @keyframes fade-in { 8 | from { 9 | opacity: 0; 10 | } 11 | } 12 | 13 | @keyframes fade-out { 14 | to { 15 | opacity: 0; 16 | } 17 | } 18 | 19 | @keyframes slide-from-right { 20 | from { 21 | transform: translateX(90px); 22 | } 23 | } 24 | 25 | @keyframes slide-to-left { 26 | to { 27 | transform: translateX(-90px); 28 | } 29 | } 30 | 31 | /* define animations for the old and new content */ 32 | ::view-transition-old(slide-it) { 33 | animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 34 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; 35 | } 36 | 37 | ::view-transition-new(slide-it) { 38 | animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 39 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; 40 | } 41 | 42 | /* tie the view transition to a given CSS class */ 43 | .sample-transition { 44 | view-transition-name: slide-it; 45 | } -------------------------------------------------------------------------------- /views/error_500.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "strconv" 4 | 5 | templ Error500(fromProtected bool, code int, reason string) { 6 |
7 |
8 |

9 | 500 10 |

11 |

12 | Internal Server Error 13 |

14 |
15 |

16 | An unexpected condition was encountered. 17 |

18 |

19 | Code: { strconv.Itoa(code) } - Reason: { reason } 20 |

21 | if !fromProtected { 22 | 23 | Go Home Page 24 | 25 | } else { 26 | 31 | Go Todo List Page 32 | 33 | } 34 |
35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Enrique Marín 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /views/home.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | templ HomeIndex(fromProtected bool) { 6 |
7 |

Welcome to our TodoList !!

8 |

9 | Here you can keep track of all your tasks and have an overview of your responsibilities. 10 |

11 | if !fromProtected { 12 |
13 |

You have an account?

14 | 30 | } 31 |
32 | } 33 | 34 | templ Home( 35 | page string, 36 | fromProtected, isError bool, 37 | msg fiber.Map, 38 | cmp templ.Component, 39 | ) { 40 | @Layout(page, fromProtected, isError, msg, "") { 41 | @cmp 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /views/partials/flashmessages_partial.templ: -------------------------------------------------------------------------------- 1 | package partials 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | templ FlashMessages(msg fiber.Map) { 6 | if msg["message"] != nil { 7 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /models/db.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func init() { 11 | MakeMigrations() 12 | } 13 | 14 | var db *sql.DB 15 | 16 | func getConnection() { 17 | var err error 18 | 19 | if db != nil { 20 | return 21 | } 22 | 23 | // Init SQLite3 database 24 | db, err = sql.Open("sqlite3", "./app_data.db") 25 | if err != nil { 26 | log.Fatalf("🔥 failed to connect to the database: %s", err.Error()) 27 | } 28 | 29 | log.Println("🚀 Connected Successfully to the Database") 30 | } 31 | 32 | func MakeMigrations() { 33 | getConnection() 34 | 35 | stmt := `CREATE TABLE IF NOT EXISTS users ( 36 | id INTEGER PRIMARY KEY AUTOINCREMENT, 37 | email VARCHAR(255) NOT NULL UNIQUE, 38 | password VARCHAR(255) NOT NULL, 39 | username VARCHAR(64) NOT NULL 40 | );` 41 | 42 | _, err := db.Exec(stmt) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | stmt = `CREATE TABLE IF NOT EXISTS todos ( 48 | id INTEGER PRIMARY KEY AUTOINCREMENT, 49 | created_by INTEGER NOT NULL, 50 | title VARCHAR(64) NOT NULL, 51 | description VARCHAR(255) NULL, 52 | status BOOLEAN DEFAULT(FALSE), 53 | created_at DATETIME default CURRENT_TIMESTAMP, 54 | FOREIGN KEY(created_by) REFERENCES users(id) 55 | );` 56 | 57 | _, err = db.Exec(stmt) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | } 62 | 63 | /* 64 | https://noties.io/blog/2019/08/19/sqlite-toggle-boolean/index.html 65 | */ 66 | -------------------------------------------------------------------------------- /models/user_model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | type User struct { 8 | ID uint64 `json:"id"` 9 | Email string `json:"email"` 10 | Password string `json:"password"` 11 | Username string `json:"username"` 12 | } 13 | 14 | func CreateUser(user User) error { 15 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), 8) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | stmt := `INSERT INTO users(email, password, username) VALUES($1, $2, $3)` 21 | 22 | _, err = db.Exec(stmt, user.Email, string(hashedPassword), user.Username) 23 | 24 | return err 25 | } 26 | 27 | func GetUserById(id string) (User, error) { 28 | query := `SELECT * FROM users WHERE id=$1` 29 | 30 | stmt, err := db.Prepare(query) 31 | if err != nil { 32 | return User{}, err 33 | } 34 | 35 | defer stmt.Close() 36 | 37 | var user User 38 | err = stmt.QueryRow(id).Scan( 39 | &user.ID, 40 | &user.Email, 41 | &user.Password, 42 | &user.Username, 43 | ) 44 | if err != nil { 45 | return User{}, err 46 | } 47 | 48 | return user, nil 49 | } 50 | 51 | func CheckEmail(email string) (User, error) { 52 | query := `SELECT * FROM users WHERE email=$1` 53 | 54 | stmt, err := db.Prepare(query) 55 | if err != nil { 56 | return User{}, err 57 | } 58 | 59 | defer stmt.Close() 60 | 61 | var user User 62 | err = stmt.QueryRow(email).Scan( 63 | &user.ID, 64 | &user.Email, 65 | &user.Password, 66 | &user.Username, 67 | ) 68 | if err != nil { 69 | return User{}, err 70 | } 71 | 72 | return user, nil 73 | } 74 | -------------------------------------------------------------------------------- /views/todo_views/create.templ: -------------------------------------------------------------------------------- 1 | package todo_views 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | 6 | "github.com/emarifer/gofiber-templ-htmx/views" 7 | ) 8 | 9 | templ CreateIndex() { 10 |

11 | Enter Task 12 |

13 |
14 |
15 | 27 | 35 | 43 |
44 |
45 | } 46 | 47 | templ Create( 48 | page string, 49 | fromProtected, isError bool, 50 | msg fiber.Map, 51 | username string, 52 | cmp templ.Component, 53 | ) { 54 | @views.Layout(page, fromProtected, isError, msg, username) { 55 | @cmp 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/emarifer/gofiber-templ-htmx/handlers" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/gofiber/fiber/v2/middleware/logger" 10 | ) 11 | 12 | func main() { 13 | app := fiber.New(fiber.Config{ 14 | // Setting centralized error hanling. 15 | ErrorHandler: handlers.CustomErrorHandler, 16 | }) 17 | 18 | app.Static("/", "./assets") 19 | 20 | app.Use(logger.New()) 21 | 22 | handlers.Setup(app) 23 | 24 | log.Fatal(app.Listen(":3000")) 25 | } 26 | 27 | /* REFERENCES: 28 | https://www.youtube.com/watch?v=Ck919fGGbCw 29 | http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/ 30 | http://cryto.net/~joepie91/blog/2016/06/19/stop-using-jwt-for-sessions-part-2-why-your-solution-doesnt-work/ 31 | 32 | https://github.com/NerdCademyDev/golang/tree/main/23_server_session_auth 33 | 34 | Attributes in Templ Components. SEE: 35 | https://templ.guide/syntax-and-usage/attributes/ 36 | 37 | https://github.com/gofiber/fiber/issues/503 38 | https://docs.gofiber.io/api/ctx/#locals 39 | 40 | https://docs.gofiber.io/guide/grouping/ 41 | https://github.com/gofiber/fiber/issues/1179 42 | https://docs.gofiber.io/extra/faq/#how-do-i-handle-custom-404-responses 43 | https://docs.gofiber.io/guide/routing/#middleware 44 | 45 | https://www.sqlite.org/foreignkeys.html 46 | 47 | https://stackoverflow.com/questions/72411062/controlling-indents-in-go-templates 48 | 49 | https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 50 | 51 | https://www.sqlitetutorial.net/sqlite-update/ 52 | 53 | https://stackoverflow.com/questions/26152088/access-a-map-value-using-a-variable-key-in-a-go-template 54 | */ 55 | -------------------------------------------------------------------------------- /views/partials/navbar_partial.templ: -------------------------------------------------------------------------------- 1 | package partials 2 | 3 | templ Navbar(fromProtected bool, username string) { 4 | 54 | } 55 | -------------------------------------------------------------------------------- /views/base_layout.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/emarifer/gofiber-templ-htmx/views/partials" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | templ Layout( 10 | page string, fromProtected, isError bool, msg fiber.Map, username string, 11 | ) { 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | Todo List{ page } 28 | 29 | 30 | 31 | 32 | 36 |
37 | if !isError { 38 | @partials.Navbar(fromProtected, username) 39 | } 40 |
41 |
42 | { children... } 43 | @partials.FlashMessages(msg) 44 |
45 | 46 | 47 | } 48 | -------------------------------------------------------------------------------- /views/todo_views/update.templ: -------------------------------------------------------------------------------- 1 | package todo_views 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/emarifer/gofiber-templ-htmx/models" 7 | "github.com/emarifer/gofiber-templ-htmx/views" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | ) 11 | 12 | templ UpdateIndex(todo models.Todo, tz string) { 13 |

14 | Update Task #{ strconv.Itoa(int(todo.ID)) } 15 |

16 |
17 |
18 | 31 | 37 | 64 |
65 |
66 | } 67 | 68 | templ Update( 69 | page string, 70 | fromProtected, isError bool, 71 | msg fiber.Map, 72 | username string, 73 | cmp templ.Component, 74 | ) { 75 | @views.Layout(page, fromProtected, isError, msg, username) { 76 | @cmp 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /views/todo_views/todo.list.templ: -------------------------------------------------------------------------------- 1 | package todo_views 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/emarifer/gofiber-templ-htmx/models" 8 | "github.com/emarifer/gofiber-templ-htmx/views" 9 | 10 | "github.com/gofiber/fiber/v2" 11 | ) 12 | 13 | templ TodoIndex(todos []models.Todo) { 14 |
15 |

16 | My Tasks List 17 |

18 | 19 | New 20 | 21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | if len(todos) != 0 { 34 | 35 | for _, todo := range todos { 36 | 37 | 38 | 39 | 46 | 80 | 81 | } 82 | 83 | } else { 84 | 85 | 86 | 89 | 90 | 91 | } 92 |
TasksStatusOptions
{ strconv.Itoa(int(todo.ID)) }{ todo.Title } 40 | if todo.Status { 41 | ✅ 42 | } else { 43 | ❌ 44 | } 45 | 47 | 52 | Edit 53 | 54 | 79 |
87 | You do not have anything to do 88 |
93 |
94 | } 95 | 96 | templ TodoList( 97 | page string, 98 | fromProtected, isError bool, 99 | msg fiber.Map, 100 | username string, 101 | cmp templ.Component, 102 | ) { 103 | @views.Layout(page, fromProtected, isError, msg, username) { 104 | @cmp 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /views/auth_views/login.templ: -------------------------------------------------------------------------------- 1 | package auth_views 2 | 3 | import ( 4 | "github.com/emarifer/gofiber-templ-htmx/views" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | templ LoginIndex(fromProtected bool) { 10 |
11 |
12 |

13 | Log In 14 |

15 |
22 | 36 | 66 | 71 |
72 |
73 |
74 | } 75 | 76 | templ Login( 77 | page string, 78 | fromProtected, isError bool, 79 | msg fiber.Map, 80 | cmp templ.Component, 81 | ) { 82 | @views.Layout(page, fromProtected, isError, msg, "") { 83 | @cmp 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /handlers/routes.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/a-h/templ" 9 | "github.com/emarifer/gofiber-templ-htmx/views" 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/gofiber/fiber/v2/middleware/adaptor" 12 | "github.com/gofiber/fiber/v2/middleware/session" 13 | "github.com/sujit-baniya/flash" 14 | ) 15 | 16 | var store *session.Store 17 | 18 | const ( 19 | AUTH_KEY string = "authenticated" 20 | USER_ID string = "user_id" 21 | FROM_PROTECTED string = "from_protected" 22 | TZONE_KEY string = "time_zone" 23 | ) 24 | 25 | func Setup(app *fiber.App) { 26 | /* Sessions Config */ 27 | store = session.New(session.Config{ 28 | CookieHTTPOnly: true, 29 | // CookieSecure: true, for https 30 | Expiration: time.Hour * 1, 31 | }) 32 | 33 | /* Views */ 34 | app.Get("/", flagsMiddleware, HandleViewHome) 35 | app.Get("/login", flagsMiddleware, HandleViewLogin) 36 | app.Post("/login", flagsMiddleware, HandleViewLogin) 37 | app.Get("/register", flagsMiddleware, HandleViewRegister) 38 | app.Post("/register", flagsMiddleware, HandleViewRegister) 39 | 40 | /* Views protected with session middleware */ 41 | todoApp := app.Group("/todo", AuthMiddleware) 42 | todoApp.Get("/list", HandleViewList) 43 | todoApp.Get("/create", HandleViewCreatePage) 44 | todoApp.Post("/create", HandleViewCreatePage) 45 | todoApp.Get("/edit/:id", HandleViewEditPage) 46 | todoApp.Post("/edit/:id", HandleViewEditPage) 47 | todoApp.Delete("/delete/:id", HandleDeleteTodo) 48 | todoApp.Post("/logout", HandleLogout) 49 | 50 | /* ↓ Not Found Management - Fallback Page ↓ */ 51 | app.Get("/*", flagsMiddleware, func(c *fiber.Ctx) error { 52 | 53 | return fiber.NewError( 54 | fiber.StatusNotFound, 55 | "error 404: not found", 56 | ) 57 | }) 58 | } 59 | 60 | // CustomErrorHandler does centralized error handling. 61 | func CustomErrorHandler(c *fiber.Ctx, err error) error { 62 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 63 | 64 | // Status code defaults to 500 65 | code := fiber.StatusInternalServerError 66 | 67 | // Retrieve the custom status code if it's a *fiber.Error 68 | var e *fiber.Error 69 | if errors.As(err, &e) { 70 | code = e.Code 71 | } 72 | 73 | c.Status(code) 74 | 75 | var errorIndex templ.Component 76 | 77 | switch code { 78 | case 404: 79 | errorIndex = views.Error404(fromProtected) 80 | default: 81 | if c.Route().Path == "/todo/list" { 82 | // If the path `/todo/list` cannot obtain the to-dos 83 | // from the database, the error page will only allow the user 84 | // to return to the home page (fromProtected = false). 85 | errorIndex = views.Error500(false, code, e.Message) 86 | } else { 87 | errorIndex = views.Error500(fromProtected, code, e.Message) 88 | } 89 | } 90 | 91 | pageTitle := fmt.Sprintf(" | Error %d", code) 92 | 93 | errorPage := views.Home( 94 | pageTitle, fromProtected, true, flash.Get(c), errorIndex, 95 | ) 96 | 97 | handler := adaptor.HTTPHandler(templ.Handler(errorPage)) 98 | 99 | return handler(c) 100 | } 101 | 102 | // flagsMiddleware is a middleware for handling different behaviors 103 | // of non protected pages, specifically not allowing an already 104 | // logged in user to log in or register again. 105 | func flagsMiddleware(c *fiber.Ctx) error { 106 | sess, _ := store.Get(c) 107 | userId := sess.Get(USER_ID) 108 | if userId == nil { 109 | c.Locals(FROM_PROTECTED, false) 110 | 111 | return c.Next() 112 | } 113 | 114 | c.Locals(FROM_PROTECTED, true) 115 | 116 | return c.Next() 117 | } 118 | -------------------------------------------------------------------------------- /views/auth_views/register.templ: -------------------------------------------------------------------------------- 1 | package auth_views 2 | 3 | import ( 4 | "github.com/emarifer/gofiber-templ-htmx/views" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | templ RegisterIndex(fromProtected bool) { 10 |
11 |
12 |

13 | Register User 14 |

15 |
21 | 35 | 65 | 80 | 85 |
86 |
87 |
88 | } 89 | 90 | templ Register( 91 | page string, 92 | fromProtected, isError bool, 93 | msg fiber.Map, 94 | cmp templ.Component, 95 | ) { 96 | @views.Layout(page, fromProtected, isError, msg, "") { 97 | @cmp 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /models/todo_model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type Todo struct { 10 | ID uint64 `json:"id"` 11 | CreatedBy uint64 `json:"created_by"` 12 | Title string `json:"title"` 13 | Description string `json:"description,omitempty"` 14 | Status bool `json:"status,omitempty"` 15 | CreatedAt time.Time `json:"created_at,omitempty"` 16 | } 17 | 18 | func (t *Todo) GetAllTodos() ([]Todo, error) { 19 | query := fmt.Sprintf("SELECT id, title, description, status FROM todos WHERE created_by = %d ORDER BY created_at DESC", t.CreatedBy) 20 | 21 | rows, err := db.Query(query) 22 | if err != nil { 23 | return []Todo{}, err 24 | } 25 | // We close the resource 26 | defer rows.Close() 27 | 28 | todos := []Todo{} 29 | for rows.Next() { 30 | rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status) 31 | 32 | todos = append(todos, *t) 33 | } 34 | 35 | return todos, nil 36 | } 37 | 38 | func (t *Todo) GetNoteById() (Todo, error) { 39 | 40 | query := `SELECT id, title, description, status, created_at FROM todos 41 | WHERE created_by = ? AND id=?` 42 | 43 | stmt, err := db.Prepare(query) 44 | if err != nil { 45 | return Todo{}, err 46 | } 47 | 48 | defer stmt.Close() 49 | 50 | var recoveredTodo Todo 51 | err = stmt.QueryRow( 52 | t.CreatedBy, t.ID, 53 | ).Scan( 54 | &recoveredTodo.ID, 55 | &recoveredTodo.Title, 56 | &recoveredTodo.Description, 57 | &recoveredTodo.Status, 58 | &recoveredTodo.CreatedAt, 59 | ) 60 | if err != nil { 61 | return Todo{}, err 62 | } 63 | 64 | return recoveredTodo, nil 65 | } 66 | 67 | func (t *Todo) CreateTodo() (Todo, error) { 68 | 69 | query := `INSERT INTO todos (created_by, title, description) 70 | VALUES(?, ?, ?) RETURNING *` 71 | 72 | stmt, err := db.Prepare(query) 73 | if err != nil { 74 | return Todo{}, err 75 | } 76 | 77 | defer stmt.Close() 78 | 79 | var newTodo Todo 80 | err = stmt.QueryRow( 81 | t.CreatedBy, 82 | t.Title, 83 | t.Description, 84 | ).Scan( 85 | &newTodo.ID, 86 | &newTodo.CreatedBy, 87 | &newTodo.Title, 88 | &newTodo.Description, 89 | &newTodo.Status, 90 | &newTodo.CreatedAt, 91 | ) 92 | if err != nil { 93 | return Todo{}, err 94 | } 95 | 96 | /* if i, err := result.RowsAffected(); err != nil || i != 1 { 97 | return errors.New("error: an affected row was expected") 98 | } */ 99 | 100 | return newTodo, nil 101 | } 102 | func (t *Todo) UpdateTodo() (Todo, error) { 103 | 104 | query := `UPDATE todos SET title = ?, description = ?, status = ? 105 | WHERE created_by = ? AND id=? RETURNING id, title, description, status` 106 | 107 | stmt, err := db.Prepare(query) 108 | if err != nil { 109 | return Todo{}, err 110 | } 111 | 112 | defer stmt.Close() 113 | 114 | var updatedTodo Todo 115 | err = stmt.QueryRow( 116 | t.Title, 117 | t.Description, 118 | t.Status, 119 | t.CreatedBy, 120 | t.ID, 121 | ).Scan( 122 | &updatedTodo.ID, 123 | &updatedTodo.Title, 124 | &updatedTodo.Description, 125 | &updatedTodo.Status, 126 | ) 127 | if err != nil { 128 | return Todo{}, err 129 | } 130 | 131 | return updatedTodo, nil 132 | } 133 | 134 | func (t *Todo) DeleteTodo() error { 135 | 136 | query := `DELETE FROM todos 137 | WHERE created_by = ? AND id=?` 138 | 139 | stmt, err := db.Prepare(query) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | defer stmt.Close() 145 | 146 | result, err := stmt.Exec(t.CreatedBy, t.ID) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | if i, err := result.RowsAffected(); err != nil || i != 1 { 152 | return errors.New("an affected row was expected") 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func ConvertDateTime(tz string, dt time.Time) string { 159 | loc, _ := time.LoadLocation(tz) 160 | 161 | return dt.In(loc).Format(time.RFC822Z) 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Go/Fiber🧬+Templ to-do list app with user login and HTMx-powered frontend (Demo) 4 | 5 | A full-stack application using Golang's Fiber framework with session-based authentication. Once we are authenticated we can enter a view from which we can manage a list of tasks (list, update and delete). Requests to the backend are controlled by [htmx](https://htmx.org/) ([hypermedia](https://hypermedia.systems/) only). 6 | 7 | ![GitHub License](https://img.shields.io/github/license/emarifer/gofiber-templ-htmx) ![Static Badge](https://img.shields.io/badge/Go-%3E=1.18-blue) 8 | 9 |

10 | 11 | >[!NOTE] 12 | >***This application is an clone of this [repository](https://github.com/emarifer/rust-axum-askama-htmx-todoapp) of mine (rust-axum-askama-htmx-todoapp), but made in `Golang`.*** 13 | 14 |
15 | 16 | ### 🤔 Explanation 17 | 18 | The application allows us to perform a complete CRUD on the database, in this case SQLite3. 19 | 20 | The DB stores both a table with the users and another table for each user's to-do. Both tables are related using a foreign key. 21 | 22 | In this application we have used the Fiber framework, which, as it says in its documentation, is "an Express-inspired web framework". This makes creating web applications much easier for people coming from the world of JavaScript. Fiber is a Go web framework built on top of Fasthttp, the fastest HTTP engine for Go. It's designed to ease things up for fast development with zero memory allocation and performance in mind. 23 | 24 | Authentication (via session) is done using built-in middleware. On the other hand, a [dependency](https://github.com/sujit-baniya/flash) is also used to handle flash messages. 25 | 26 | Finally, using the Fiber framework also allows us centralized error handling. 27 | 28 |
29 | 30 | >[!IMPORTANT] 31 | >***In this application, instead of using the html/template package (gofiber/template/html, specifically), we use the [a-h/templ](https://github.com/a-h/templ) library. This amazing library implements a templating language (very similar to JSX) that compiles to Go code. Templ will allow us to write code almost identical to Go (with expressions, control flow, if/else, for loops, etc.) and have autocompletion thanks to strong typing. This means that errors appear at compile time and any calls to these templates (which are compiled as Go functions) from the handlers side will always require the correct data, minimizing errors and thus increasing the security and speed of our coding.*** 32 | 33 | The use of htmx allows behavior similar to that of a SPA, without page reloads when switching from one route to another or when making requests (via AJAX) to the backend. 34 | 35 | On the other hand, the styling of the views is achieved through Tailwind CSS and DaisyUI that are obtained from their respective CDNs. Likewise, the `SweetAlert2` library is used, a substitute for javascript pop-up boxes. In the same way it is obtained from its respective CDN. 36 | 37 | Finally, minimal use of [_hyperscript](https://hyperscript.org/) is made to achieve the action of closing alerts when they are displayed and the action of showing/hiding the password in corresponding text input fields. 38 | 39 | >[!NOTE] 40 | >***This application is identical, although much more improved, to that of a previous [repository](https://github.com/emarifer/gofiber-htmx-todolist) of mine, which is developed in GoFiber-template/html instead of [Templ](https://templ.guide/) templating language, as in this case.*** 41 | 42 | --- 43 | 44 | ## 🖼️ Screenshots: 45 | 46 |
47 | 48 | ###### Todo List Page with success alert & Sign Up Page with error alert: 49 | 50 |    51 | 52 |
53 | 54 | ###### Task update page & popup alert based on SweetAlert2: 55 | 56 |    57 | 58 |
59 | 60 | ###### Centralized handling of 404 & 500 errors: 61 | 62 |    63 | 64 |
65 | 66 |
67 | 68 | --- 69 | 70 | ## 👨‍🚀 Setup: 71 | 72 | Besides the obvious prerequisite of having Go! on your machine, you must have Air installed for hot reloading when editing code. 73 | 74 | Start the app in development mode: 75 | 76 | ``` 77 | $ air # This also compiles the view templates automatically / Ctrl + C to stop the application 78 | ``` 79 | 80 | Build for production (previously it is necessary to regenerate the templates with the `templ generate` command; see explanation below): 81 | 82 | ``` 83 | $ go build -ldflags="-s -w" -o ./bin/main . # ./bin/main to run the application / Ctrl + C to stop the application 84 | ``` 85 | 86 | >[!TIP] 87 | >***In order to have autocompletion and syntax highlighting in VS Code for the Teml templating language, you will have to install the [templ-vscode](https://marketplace.visualstudio.com/items?itemName=a-h.templ) extension (for vim/nvim install this [plugin](https://github.com/joerdav/templ.vim)). To generate the Go code corresponding to these templates you will have to install the templ CLI:*** 88 | 89 | ``` 90 | $ go install github.com/a-h/templ/cmd/templ@latest 91 | ``` 92 | 93 | >[!TIP] 94 | >***And then regenerate the templates with the command:*** 95 | 96 | ``` 97 | $ templ generate # The `templ generate --watch` command will watch the project folder to regenerate them every time we make a change to its code. 98 | ``` 99 | 100 | >[!TIP] 101 | >***Review the documentation on Templ [installation](https://templ.guide/quick-start/installation) and [support](https://templ.guide/commands-and-tools/ide-support/) for your IDE.*** 102 | 103 | --- 104 | 105 | ### Happy coding 😀!! -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.2.476 h1:+H4hP4CwK4kfJwXsE6kHeFWMGtcVOVoOm/I64uzARBk= 2 | github.com/a-h/templ v0.2.476/go.mod h1:zQ95mSyadNTGHv6k5Fm+wQU8zkBMMbHCHg7eAvUZKNM= 3 | github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= 4 | github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= 5 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 6 | github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= 7 | github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 8 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 9 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 10 | github.com/gofiber/fiber/v2 v2.36.0/go.mod h1:tgCr+lierLwLoVHHO/jn3Niannv34WRkQETU8wiL9fQ= 11 | github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ= 12 | github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U= 13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= 16 | github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 18 | github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= 19 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 20 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 21 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 22 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 23 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 24 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 25 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 26 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 27 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 29 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 30 | github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= 31 | github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 32 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/sujit-baniya/flash v0.1.8 h1:BwcrybCatPU30VMA9IBA5q3ZE0VSr5c7qTqwZrSvyRI= 35 | github.com/sujit-baniya/flash v0.1.8/go.mod h1:kmlAIkLDMlLshEeeE6fETEW8kSOopKN5WA3KXLmS/U0= 36 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 37 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 38 | github.com/valyala/fasthttp v1.38.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= 39 | github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M= 40 | github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= 41 | github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= 42 | github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= 43 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 44 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 45 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 46 | golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= 47 | golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 48 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 49 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 50 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 51 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 52 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 60 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 62 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 64 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 65 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 66 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 67 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 68 | -------------------------------------------------------------------------------- /handlers/auth_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/a-h/templ" 9 | "github.com/emarifer/gofiber-templ-htmx/models" 10 | "github.com/emarifer/gofiber-templ-htmx/views" 11 | "github.com/emarifer/gofiber-templ-htmx/views/auth_views" 12 | "github.com/gofiber/fiber/v2" 13 | "github.com/gofiber/fiber/v2/middleware/adaptor" 14 | "github.com/sujit-baniya/flash" 15 | "golang.org/x/crypto/bcrypt" 16 | ) 17 | 18 | /********** Handlers for Auth Views **********/ 19 | 20 | // Render Home Page 21 | func HandleViewHome(c *fiber.Ctx) error { 22 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 23 | 24 | hindex := views.HomeIndex(fromProtected) 25 | home := views.Home("", fromProtected, false, flash.Get(c), hindex) 26 | 27 | handler := adaptor.HTTPHandler(templ.Handler(home)) 28 | 29 | return handler(c) 30 | } 31 | 32 | // Render Login Page with success/error messages & session management 33 | func HandleViewLogin(c *fiber.Ctx) error { 34 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 35 | 36 | lindex := auth_views.LoginIndex(fromProtected) 37 | login := auth_views.Login( 38 | " | Login", fromProtected, false, flash.Get(c), lindex, 39 | ) 40 | 41 | handler := adaptor.HTTPHandler(templ.Handler(login)) 42 | 43 | if c.Method() == "POST" { 44 | // obtaining the time zone from the POST request of the login form 45 | tzone := "" 46 | if len(c.GetReqHeaders()["X-Timezone"]) != 0 { 47 | tzone = c.GetReqHeaders()["X-Timezone"][0] 48 | // fmt.Println("Tzone:", tzone) 49 | } 50 | 51 | var ( 52 | user models.User 53 | err error 54 | ) 55 | fm := fiber.Map{ 56 | "type": "error", 57 | } 58 | 59 | // notice: in production you should not inform the user 60 | // with detailed messages about login failures 61 | if user, err = models.CheckEmail(c.FormValue("email")); err != nil { 62 | // fmt.Println(err) 63 | if strings.Contains(err.Error(), "no such table") || 64 | strings.Contains(err.Error(), "database is locked") { 65 | // "no such table" is the error that SQLite3 produces 66 | // when some table does not exist, and we have only 67 | // used it as an example of the errors that can be caught. 68 | // Here you can add the errors that you are interested 69 | // in throwing as `500` codes. 70 | return fiber.NewError( 71 | fiber.StatusServiceUnavailable, 72 | "database temporarily out of service", 73 | ) 74 | } 75 | fm["message"] = "There is no user with that email" 76 | 77 | return flash.WithError(c, fm).Redirect("/login") 78 | } 79 | 80 | err = bcrypt.CompareHashAndPassword( 81 | []byte(user.Password), 82 | []byte(c.FormValue("password")), 83 | ) 84 | if err != nil { 85 | fm["message"] = "Incorrect password" 86 | 87 | return flash.WithError(c, fm).Redirect("/login") 88 | } 89 | 90 | session, err := store.Get(c) 91 | if err != nil { 92 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 93 | 94 | return flash.WithError(c, fm).Redirect("/login") 95 | } 96 | 97 | session.Set(AUTH_KEY, true) 98 | session.Set(USER_ID, user.ID) 99 | session.Set(TZONE_KEY, tzone) 100 | 101 | err = session.Save() 102 | if err != nil { 103 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 104 | 105 | return flash.WithError(c, fm).Redirect("/login") 106 | } 107 | 108 | fm = fiber.Map{ 109 | "type": "success", 110 | "message": "You have successfully logged in!!", 111 | } 112 | 113 | return flash.WithSuccess(c, fm).Redirect("/todo/list") 114 | } 115 | 116 | return handler(c) 117 | } 118 | 119 | // Render Register Page with success/error messages 120 | func HandleViewRegister(c *fiber.Ctx) error { 121 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 122 | 123 | rindex := auth_views.RegisterIndex(fromProtected) 124 | register := auth_views.Register( 125 | " | Register", fromProtected, false, flash.Get(c), rindex, 126 | ) 127 | 128 | handler := adaptor.HTTPHandler(templ.Handler(register)) 129 | 130 | if c.Method() == "POST" { 131 | user := models.User{ 132 | Email: c.FormValue("email"), 133 | Password: c.FormValue("password"), 134 | Username: c.FormValue("username"), 135 | } 136 | 137 | err := models.CreateUser(user) 138 | if err != nil { 139 | if strings.Contains(err.Error(), "no such table") || 140 | strings.Contains(err.Error(), "database is locked") { 141 | // "no such table" is the error that SQLite3 produces 142 | // when some table does not exist, and we have only 143 | // used it as an example of the errors that can be caught. 144 | // Here you can add the errors that you are interested 145 | // in throwing as `500` codes. 146 | return fiber.NewError( 147 | fiber.StatusServiceUnavailable, 148 | "database temporarily out of service", 149 | ) 150 | } 151 | if strings.Contains(err.Error(), "UNIQUE constraint failed") { 152 | err = errors.New("the email is already in use") 153 | } 154 | fm := fiber.Map{ 155 | "type": "error", 156 | "message": fmt.Sprintf("something went wrong: %s", err), 157 | } 158 | 159 | return flash.WithError(c, fm).Redirect("/register") 160 | } 161 | 162 | fm := fiber.Map{ 163 | "type": "success", 164 | "message": "You have successfully registered!!", 165 | } 166 | 167 | return flash.WithSuccess(c, fm).Redirect("/login") 168 | } 169 | 170 | return handler(c) 171 | } 172 | 173 | // Authentication Middleware 174 | func AuthMiddleware(c *fiber.Ctx) error { 175 | fm := fiber.Map{ 176 | "type": "error", 177 | } 178 | 179 | session, err := store.Get(c) 180 | if err != nil { 181 | fm["message"] = "You are not authorized" 182 | 183 | return flash.WithError(c, fm).Redirect("/login") 184 | } 185 | 186 | if session.Get(AUTH_KEY) == nil { 187 | fm["message"] = "You are not authorized" 188 | 189 | return flash.WithError(c, fm).Redirect("/login") 190 | } 191 | 192 | userId := session.Get(USER_ID) 193 | if userId == nil { 194 | fm["message"] = "You are not authorized" 195 | 196 | return flash.WithError(c, fm).Redirect("/login") 197 | } 198 | 199 | user, err := models.GetUserById(fmt.Sprint(userId.(uint64))) 200 | if err != nil { 201 | fm["message"] = "You are not authorized" 202 | 203 | return flash.WithError(c, fm).Redirect("/login") 204 | } 205 | 206 | c.Locals("userId", userId) 207 | c.Locals("username", user.Username) 208 | c.Locals(FROM_PROTECTED, true) 209 | // fromProtected = true 210 | 211 | return c.Next() 212 | } 213 | 214 | // Logout Handler 215 | func HandleLogout(c *fiber.Ctx) error { 216 | fm := fiber.Map{ 217 | "type": "error", 218 | } 219 | 220 | session, err := store.Get(c) 221 | if err != nil { 222 | fm["message"] = "logged out (no session)" 223 | 224 | return flash.WithError(c, fm).Redirect("/login") 225 | } 226 | 227 | err = session.Destroy() 228 | if err != nil { 229 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 230 | 231 | return flash.WithError(c, fm).Redirect("/login") 232 | } 233 | 234 | fm = fiber.Map{ 235 | "type": "success", 236 | "message": "You have successfully logged out!!", 237 | } 238 | 239 | c.Locals(FROM_PROTECTED, false) 240 | // fromProtected = false 241 | 242 | return flash.WithSuccess(c, fm).Redirect("/login") 243 | } 244 | -------------------------------------------------------------------------------- /handlers/todo_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/a-h/templ" 9 | "github.com/emarifer/gofiber-templ-htmx/models" 10 | "github.com/emarifer/gofiber-templ-htmx/views/todo_views" 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/gofiber/fiber/v2/middleware/adaptor" 13 | "github.com/sujit-baniya/flash" 14 | ) 15 | 16 | /********** Handlers for Todo Views **********/ 17 | 18 | // Render List Page with success/error messages 19 | func HandleViewList(c *fiber.Ctx) error { 20 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 21 | 22 | todo := new(models.Todo) 23 | todo.CreatedBy = c.Locals("userId").(uint64) 24 | 25 | // fm := fiber.Map{"type": "error"} 26 | 27 | todosSlice, err := todo.GetAllTodos() 28 | if err != nil { 29 | if strings.Contains(err.Error(), "no such table") || 30 | strings.Contains(err.Error(), "database is locked") { 31 | // "no such table" is the error that SQLite3 produces 32 | // when some table does not exist, and we have only 33 | // used it as an example of the errors that can be caught. 34 | // Here you can add the errors that you are interested 35 | // in throwing as `500` codes. 36 | return fiber.NewError( 37 | fiber.StatusServiceUnavailable, 38 | "database temporarily out of service", 39 | ) 40 | } 41 | // fm["message"] = fmt.Sprintf("something went wrong: %s", err) 42 | 43 | // return flash.WithError(c, fm).Redirect("/todo/list") 44 | } 45 | 46 | tindex := todo_views.TodoIndex(todosSlice) 47 | tlist := todo_views.TodoList( 48 | " | Tasks List", 49 | fromProtected, 50 | false, 51 | flash.Get(c), 52 | c.Locals("username").(string), 53 | tindex, 54 | ) 55 | 56 | handler := adaptor.HTTPHandler(templ.Handler(tlist)) 57 | 58 | return handler(c) 59 | } 60 | 61 | // Render Create Todo Page with success/error messages 62 | func HandleViewCreatePage(c *fiber.Ctx) error { 63 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 64 | 65 | if c.Method() == "POST" { 66 | newTodo := new(models.Todo) 67 | newTodo.CreatedBy = c.Locals("userId").(uint64) 68 | newTodo.Title = strings.Trim(c.FormValue("title"), " ") 69 | newTodo.Description = strings.Trim(c.FormValue("description"), " ") 70 | 71 | fm := fiber.Map{ 72 | "type": "error", 73 | "message": "Task title empty!!", 74 | } 75 | if newTodo.Title == "" { 76 | 77 | return flash.WithError(c, fm).Redirect("/todo/list") 78 | } 79 | 80 | if _, err := newTodo.CreateTodo(); err != nil { 81 | if strings.Contains(err.Error(), "no such table") || 82 | strings.Contains(err.Error(), "database is locked") { 83 | // "no such table" is the error that SQLite3 produces 84 | // when some table does not exist, and we have only 85 | // used it as an example of the errors that can be caught. 86 | // Here you can add the errors that you are interested 87 | // in throwing as `500` codes. 88 | return fiber.NewError( 89 | fiber.StatusServiceUnavailable, 90 | "database temporarily out of service", 91 | ) 92 | } 93 | } 94 | 95 | fm = fiber.Map{ 96 | "type": "success", 97 | "message": "Task successfully created!!", 98 | } 99 | 100 | return flash.WithSuccess(c, fm).Redirect("/todo/list") 101 | } 102 | 103 | cindex := todo_views.CreateIndex() 104 | create := todo_views.Create( 105 | " | Create Todo", 106 | fromProtected, 107 | false, 108 | flash.Get(c), 109 | c.Locals("username").(string), 110 | cindex, 111 | ) 112 | 113 | handler := adaptor.HTTPHandler(templ.Handler(create)) 114 | 115 | return handler(c) 116 | } 117 | 118 | // Render Edit Todo Page with success/error messages 119 | func HandleViewEditPage(c *fiber.Ctx) error { 120 | fromProtected := c.Locals(FROM_PROTECTED).(bool) 121 | session, _ := store.Get(c) 122 | tzone := session.Get(TZONE_KEY).(string) 123 | 124 | idParams, _ := strconv.Atoi(c.Params("id")) 125 | todoId := uint64(idParams) 126 | 127 | todo := new(models.Todo) 128 | todo.ID = todoId 129 | todo.CreatedBy = c.Locals("userId").(uint64) 130 | 131 | fm := fiber.Map{"type": "error"} 132 | 133 | recoveredTodo, err := todo.GetNoteById() 134 | 135 | if err != nil { 136 | if strings.Contains(err.Error(), "no such table") || 137 | strings.Contains(err.Error(), "database is locked") { 138 | // "no such table" is the error that SQLite3 produces 139 | // when some table does not exist, and we have only 140 | // used it as an example of the errors that can be caught. 141 | // Here you can add the errors that you are interested 142 | // in throwing as `500` codes. 143 | return fiber.NewError( 144 | fiber.StatusServiceUnavailable, 145 | "database temporarily out of service", 146 | ) 147 | } 148 | 149 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 150 | 151 | return flash.WithError(c, fm).Redirect("/todo/list") 152 | } 153 | 154 | if c.Method() == "POST" { 155 | todo.Title = strings.Trim(c.FormValue("title"), " ") 156 | todo.Description = strings.Trim(c.FormValue("description"), " ") 157 | if c.FormValue("status") == "on" { 158 | todo.Status = true 159 | } else { 160 | todo.Status = false 161 | } 162 | 163 | fm = fiber.Map{ 164 | "type": "error", 165 | "message": "Task title empty!!", 166 | } 167 | if todo.Title == "" { 168 | 169 | return flash.WithError(c, fm).Redirect("/todo/list") 170 | } 171 | 172 | _, err := todo.UpdateTodo() 173 | if err != nil { 174 | if strings.Contains(err.Error(), "no such table") || 175 | strings.Contains(err.Error(), "database is locked") { 176 | // "no such table" is the error that SQLite3 produces 177 | // when some table does not exist, and we have only 178 | // used it as an example of the errors that can be caught. 179 | // Here you can add the errors that you are interested 180 | // in throwing as `500` codes. 181 | return fiber.NewError( 182 | fiber.StatusServiceUnavailable, 183 | "database temporarily out of service", 184 | ) 185 | } 186 | 187 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 188 | 189 | return flash.WithError(c, fm).Redirect("/todo/list") 190 | } 191 | 192 | fm = fiber.Map{ 193 | "type": "success", 194 | "message": "Task successfully updated!!", 195 | } 196 | 197 | return flash.WithSuccess(c, fm).Redirect("/todo/list") 198 | } 199 | 200 | uindex := todo_views.UpdateIndex(recoveredTodo, tzone) 201 | update := todo_views.Update( 202 | fmt.Sprintf(" | Edit Todo #%d", recoveredTodo.ID), 203 | fromProtected, 204 | false, 205 | flash.Get(c), 206 | c.Locals("username").(string), 207 | uindex, 208 | ) 209 | 210 | handler := adaptor.HTTPHandler(templ.Handler(update)) 211 | 212 | return handler(c) 213 | } 214 | 215 | // Handler Remove Todo 216 | func HandleDeleteTodo(c *fiber.Ctx) error { 217 | idParams, _ := strconv.Atoi(c.Params("id")) 218 | todoId := uint64(idParams) 219 | 220 | todo := new(models.Todo) 221 | todo.ID = todoId 222 | todo.CreatedBy = c.Locals("userId").(uint64) 223 | 224 | fm := fiber.Map{"type": "error"} 225 | 226 | if err := todo.DeleteTodo(); err != nil { 227 | if strings.Contains(err.Error(), "no such table") || 228 | strings.Contains(err.Error(), "database is locked") { 229 | // "no such table" is the error that SQLite3 produces 230 | // when some table does not exist, and we have only 231 | // used it as an example of the errors that can be caught. 232 | // Here you can add the errors that you are interested 233 | // in throwing as `500` codes. 234 | return fiber.NewError( 235 | fiber.StatusServiceUnavailable, 236 | "database temporarily out of service", 237 | ) 238 | } 239 | fm["message"] = fmt.Sprintf("something went wrong: %s", err) 240 | 241 | return flash.WithError(c, fm).Redirect( 242 | "/todo/list", 243 | fiber.StatusSeeOther, 244 | ) 245 | } 246 | 247 | fm = fiber.Map{ 248 | "type": "success", 249 | "message": "Task successfully deleted!!", 250 | } 251 | 252 | return flash.WithSuccess(c, fm).Redirect("/todo/list", fiber.StatusSeeOther) 253 | } 254 | -------------------------------------------------------------------------------- /assets/img/gopher-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | --------------------------------------------------------------------------------