├── .gitignore ├── static ├── img │ ├── htmx-logo.png │ └── gopher-svgrepo-com.svg └── css │ └── input.css ├── go.mod ├── go.sum ├── package.json ├── tailwind.config.js ├── views ├── partials │ ├── button-up.html │ ├── modal.html │ ├── header.html │ ├── note-list.html │ └── footer.html ├── about.html ├── layouts │ └── base.layout.html └── index.html ├── .air.toml ├── db.go ├── LICENSE ├── main.go ├── README.md ├── note.model.go └── handlers.go /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | bin 3 | node_modules 4 | static/css/output.css 5 | notesDB.sqlite -------------------------------------------------------------------------------- /static/img/htmx-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/go-htmx-demo/HEAD/static/img/htmx-logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emarifer/go-htmx-demo 2 | 3 | go 1.21.0 4 | 5 | require github.com/mattn/go-sqlite3 v1.14.17 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 2 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "watch-css": "npx tailwindcss -i static/css/input.css -o static/css/output.css --watch", 4 | "build-css-prod": "npx tailwindcss -i static/css/input.css -o static/css/output.css --minify" 5 | }, 6 | "devDependencies": { 7 | "daisyui": "^3.9.3", 8 | "tailwindcss": "^3.3.3" 9 | } 10 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./views/**/*.{html,js}"], 4 | theme: { 5 | extend: { 6 | fontFamily: { 7 | Kanit: ["Kanit, sans-serif"], 8 | }, 9 | }, 10 | }, 11 | plugins: [require("daisyui")], 12 | daisyui: { 13 | themes: ["dark"], 14 | }, 15 | } 16 | 17 | -------------------------------------------------------------------------------- /views/partials/button-up.html: -------------------------------------------------------------------------------- 1 | {{ define "button-up" }} 2 | 11 | {{end }} -------------------------------------------------------------------------------- /views/partials/modal.html: -------------------------------------------------------------------------------- 1 | {{ define "modal" }} 2 | 14 | {{ end }} -------------------------------------------------------------------------------- /views/about.html: -------------------------------------------------------------------------------- 1 | {{ template "base.layout.start" . }} 2 |
3 |

4 | 5 | Here you can see something about me 6 | 7 |

8 | 9 |
10 | 12 | ← Go Home 13 | 14 |
15 |
16 | {{ template "base.layout.end" . }} -------------------------------------------------------------------------------- /views/partials/header.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 |
3 | 20 |
21 | {{end }} -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "10s" 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 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | ) 7 | 8 | var db *sql.DB 9 | 10 | func GetConnection() *sql.DB { 11 | if db != nil { 12 | return db 13 | } 14 | 15 | db, err := sql.Open("sqlite3", "notesDB.sqlite") 16 | if err != nil { 17 | log.Fatalf("🔥 failed to connect to the database: %s", err.Error()) 18 | } 19 | 20 | log.Println("🚀 Connected Successfully to the Database") 21 | 22 | return db 23 | } 24 | 25 | func MakeMigrations() error { 26 | db := GetConnection() 27 | 28 | stmt := `CREATE TABLE IF NOT EXISTS notes ( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | title VARCHAR(64) UNIQUE CHECK(title IS NULL OR length(title) <= 64), 31 | description VARCHAR(255) NULL, 32 | completed BOOLEAN DEFAULT(FALSE), 33 | created_at TIMESTAMP DEFAULT DATETIME 34 | );` 35 | 36 | _, err := db.Exec(stmt) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | /* 45 | https://noties.io/blog/2019/08/19/sqlite-toggle-boolean/index.html 46 | */ 47 | -------------------------------------------------------------------------------- /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/layouts/base.layout.html: -------------------------------------------------------------------------------- 1 | {{ define "base.layout.start" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ .Title }} 14 | 17 | 18 | 19 | 20 | 21 | 22 | {{ template "header" .}} 23 | 24 |
25 | {{ end }} 26 | 27 | 28 | {{ define "base.layout.end" }} 29 |
30 | 31 | {{ template "footer" .}} 32 | 33 | {{ template "button-up" .}} 34 | 35 | 36 | 37 | 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /views/partials/note-list.html: -------------------------------------------------------------------------------- 1 | {{ define "note-list" }} 2 | 43 | {{ end }} -------------------------------------------------------------------------------- /views/partials/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 | 13 | {{end }} -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | {{ template "base.layout.start" . }} 2 |
3 |

Add Note

4 | 5 |
8 | {{ block "new-note-form" . }} 9 |
10 |
11 | 12 | 14 | 15 | {{ .ErrTitle }} 16 | 17 |
18 |
19 | 20 | 22 | 23 | {{ .ErrDescription }} 24 | 25 |
26 |
27 | 28 | 31 | {{ end }} 32 |
33 |
34 | 35 | 36 | 37 |
44 | {{ template "note-list" .}} 45 |
46 | {{ template "base.layout.end" .}} -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func init() { 9 | MakeMigrations() 10 | } 11 | 12 | func main() { 13 | 14 | http.HandleFunc("/", ShowHomePage) 15 | http.HandleFunc("/about", ShowAboutPage) 16 | http.HandleFunc("/notes", GetNotes) 17 | http.HandleFunc("/add-note", AddNote) 18 | http.HandleFunc("/update-note/", CompleteNote) 19 | http.HandleFunc("/delete-note/", RemoveNote) 20 | 21 | fs := http.FileServer(http.Dir("./static")) 22 | http.Handle("/static/", http.StripPrefix("/static/", fs)) 23 | 24 | log.Println("🚀 Starting up on port 3000") 25 | 26 | log.Fatal(http.ListenAndServe(":3000", nil)) 27 | } 28 | 29 | /* REFERENCES: 30 | https://medium.com/@orlmonteverde/api-rest-con-go-golang-y-sqlite3-e378af30719c 31 | https://medium.com/@back_to_basics/golang-template-1-bcb690165663 32 | https://www.alexedwards.net/blog/working-with-cookies-in-go 33 | https://lets-go.alexedwards.net/sample/02.07-html-templating-and-inheritance.html 34 | 35 | https://htmx.org/attributes/hx-swap/ 36 | https://htmx.org/attributes/hx-target/ 37 | https://htmx.org/attributes/hx-boost/ 38 | https://hypermedia.systems/htmx-in-action/#_ajax_ifying_our_application 39 | https://htmx.org/headers/hx-location/ 40 | https://htmx.org/essays/view-transitions/ 41 | https://htmx.org/docs/#special-events 42 | https://htmx.org/attributes/hx-trigger/#standard-event-modifiers 43 | https://htmx.org/examples/update-other-content/ 44 | https://www.jetbrains.com/guide/dotnet/tutorials/htmx-aspnetcore/out-of-band-swaps/ 45 | https://www.youtube.com/watch?v=g7Nlo6N2hAk 46 | https://hyperscript.org/ 47 | https://hypermedia.systems/book/contents/ 48 | 49 | https://www.calhoun.io/intro-to-templates-p3-functions/ 50 | https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go 51 | https://pkg.go.dev/text/template 52 | https://golangforall.com/en/post/templates.html 53 | https://krishbhanushali.medium.com/3-simple-techniques-using-go-templates-6718e2cc77e2 54 | https://blog.logrocket.com/using-golang-templates/ 55 | 56 | https://github.com/moeenn/htmx-golang-demo 57 | https://github.com/orlmonteverde/go-api-with-sqlite 58 | https://github.com/NerdCademyDev/gophat 59 | https://github.com/bugbytes-io/htmx-go-demo 60 | https://github.com/awesome-club/go-htmx 61 | https://github.com/marco-souza/marco.fly.dev 62 | */ 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Golang Full-stack Web App 2 | 3 | #### Simple full-stack web application for saving notes to a Sqlite database (CRUD), with HTML template rendering using htmx & _hyperscript. 4 | 5 | #### This is a minimalist application that does not use any additional libraries beyond the standard Go library, except for the necessary driver to manage the Sqlite database, thus following the trend of Golang developers to only use dependencies strictly necessary, taking advantage of all the power of the standard library. 6 | 7 | #### Rendering is achieved by using the "html/template" package, i.e. Go's native form of rendering, and the " htmx" JavaScript library. The latter makes it possible to make requests to the backend (GET, POST, PATCH and DELETE) without reloading the page as in a SPA, but with a size of said library of only 15K. Additionally, "_hyperscript" is used, another JavaScript library developed by the same author with the purpose of performing a few actions by writing a kind of inline JavaScript code. 8 | 9 | ###### **Note**: the theoretical bases of Htmx and illustrative examples of its correct use can be consulted in the book HYPERMEDIA SYSTEMS written by the same developer of the library, Carson Gross, in [this site](https://hypermedia.systems/). 10 | 11 | --- 12 | 13 | ### Screenshots: 14 | 15 | ###### Homepage: 16 | 17 | 18 | 19 |
20 | 21 | ###### Error reporting modal: 22 | 23 | 24 | 25 | --- 26 | 27 | ### Setup: 28 | 29 | Besides the obvious prerequisite of having Go! on your machine, you must have Air installed for hot reloading when editing code and NodeJs. 30 | 31 | Since the application uses Tailwind as a CSS framework, you must run some NodeJs commands in the project root before starting the application: 32 | 33 | ``` 34 | $npm i 35 | ``` 36 | 37 | Next, whether you want to make code changes or create production CSS, you need to run these commands: 38 | 39 | ``` 40 | # If you want to edit the code and update the build CSS: 41 | 42 | $npm run watch-css 43 | 44 | # If you want to create the production CSS: 45 | 46 | $npm run build-css-prod 47 | ``` 48 | 49 | Start the app in development mode: 50 | 51 | ``` 52 | $ air # Ctrl + C to stop the application 53 | ``` 54 | 55 | Build for production: 56 | 57 | ``` 58 | $ go build -ldflags="-s -w" -o ./bin/main . # ./bin/main to run the application 59 | ``` 60 | 61 | ### Happy coding 😀!! 62 | -------------------------------------------------------------------------------- /static/css/input.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Kanit&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | body { 9 | @apply font-Kanit; 10 | } 11 | } 12 | 13 | @keyframes fade-in { 14 | from { 15 | opacity: 0; 16 | } 17 | } 18 | 19 | @keyframes fade-out { 20 | to { 21 | opacity: 0; 22 | } 23 | } 24 | 25 | @keyframes slide-from-right { 26 | from { 27 | transform: translateX(90px); 28 | } 29 | } 30 | 31 | @keyframes slide-to-left { 32 | to { 33 | transform: translateX(-90px); 34 | } 35 | } 36 | 37 | /* define animations for the old and new content */ 38 | ::view-transition-old(slide-it) { 39 | animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 40 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; 41 | } 42 | 43 | ::view-transition-new(slide-it) { 44 | animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 45 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; 46 | } 47 | 48 | /* tie the view transition to a given CSS class */ 49 | .sample-transition { 50 | view-transition-name: slide-it; 51 | } 52 | 53 | /***** MODAL DIALOG ****/ 54 | #modal { 55 | /* Underlay covers entire screen. */ 56 | position: fixed; 57 | top: 0px; 58 | bottom: 0px; 59 | left: 0px; 60 | right: 0px; 61 | background-color: rgba(0, 0, 0, 0.5); 62 | z-index: 1000; 63 | 64 | /* Flexbox centers the .modal-content vertically and horizontally */ 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | 69 | /* Animate when opening */ 70 | animation-name: fadeIn; 71 | animation-duration: 300ms; 72 | animation-timing-function: ease; 73 | } 74 | 75 | #modal>.modal-underlay { 76 | /* underlay takes up the entire viewport. This is only 77 | required if you want to click to dismiss the popup */ 78 | position: absolute; 79 | z-index: -1; 80 | top: 0px; 81 | bottom: 0px; 82 | left: 0px; 83 | right: 0px; 84 | } 85 | 86 | #modal>.modal-content { 87 | /* color: black; */ 88 | /* Position visible dialog near the top of the window */ 89 | margin-top: 10vh; 90 | 91 | /* Sizing for visible dialog */ 92 | width: 80%; 93 | max-width: 400px; 94 | 95 | /* Display properties for visible dialog*/ 96 | /* border: solid 1px #999; 97 | border-radius: 8px; 98 | box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.3); 99 | background-color: white; 100 | padding: 20px; */ 101 | 102 | /* Animate when opening */ 103 | animation-name: zoomIn; 104 | animation-duration: 300ms; 105 | animation-timing-function: ease; 106 | } 107 | 108 | #modal.closing { 109 | /* Animate when closing */ 110 | animation-name: fadeOut; 111 | animation-duration: 300ms; 112 | animation-timing-function: ease; 113 | } 114 | 115 | #modal.closing>.modal-content { 116 | /* Animate when closing */ 117 | animation-name: zoomOut; 118 | animation-duration: 300ms; 119 | animation-timing-function: ease; 120 | } 121 | 122 | @keyframes fadeIn { 123 | 0% { 124 | opacity: 0; 125 | } 126 | 127 | 100% { 128 | opacity: 1; 129 | } 130 | } 131 | 132 | @keyframes fadeOut { 133 | 0% { 134 | opacity: 1; 135 | } 136 | 137 | 100% { 138 | opacity: 0; 139 | } 140 | } 141 | 142 | @keyframes zoomIn { 143 | 0% { 144 | transform: scale(0.8); 145 | } 146 | 147 | 100% { 148 | transform: scale(1); 149 | } 150 | } 151 | 152 | @keyframes zoomOut { 153 | 0% { 154 | transform: scale(1); 155 | } 156 | 157 | 100% { 158 | transform: scale(0.8); 159 | } 160 | } -------------------------------------------------------------------------------- /note.model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | _ "github.com/mattn/go-sqlite3" 9 | ) 10 | 11 | type Note struct { 12 | ID int `json:"id,omitempty"` 13 | Title string `json:"title"` 14 | Description string `json:"description"` 15 | Completed bool `json:"completed,omitempty"` 16 | CreatedAt time.Time `json:"created_at,omitempty"` 17 | } 18 | 19 | type ConvertedNote struct { 20 | ID int `json:"id,omitempty"` 21 | Title string `json:"title"` 22 | Description string `json:"description"` 23 | Completed bool `json:"completed,omitempty"` 24 | CreatedAt string `json:"created_at,omitempty"` 25 | } 26 | 27 | func (n *Note) CreateNote() (Note, error) { 28 | db := GetConnection() 29 | 30 | query := `INSERT INTO notes (title, description, created_at) 31 | VALUES(?, ?, ?) RETURNING *` 32 | 33 | stmt, err := db.Prepare(query) 34 | if err != nil { 35 | return Note{}, err 36 | } 37 | 38 | defer stmt.Close() 39 | 40 | var newNote Note 41 | err = stmt.QueryRow( 42 | n.Title, 43 | n.Description, 44 | time.Now().UTC(), 45 | ).Scan( 46 | &newNote.ID, 47 | &newNote.Title, 48 | &newNote.Description, 49 | &newNote.Completed, 50 | &newNote.CreatedAt, 51 | ) 52 | if err != nil { 53 | return Note{}, err 54 | } 55 | 56 | /* if i, err := result.RowsAffected(); err != nil || i != 1 { 57 | return errors.New("error: an affected row was expected") 58 | } */ 59 | 60 | return newNote, nil 61 | } 62 | 63 | func (n *Note) GetAllNotes(offset int) ([]Note, error) { 64 | db := GetConnection() 65 | query := fmt.Sprintf("SELECT * FROM notes ORDER BY created_at DESC LIMIT 5 OFFSET %d", offset) 66 | 67 | rows, err := db.Query(query) 68 | if err != nil { 69 | return []Note{}, err 70 | } 71 | // Cerramos el recurso 72 | defer rows.Close() 73 | 74 | notes := []Note{} 75 | for rows.Next() { 76 | rows.Scan(&n.ID, &n.Title, &n.Description, &n.Completed, &n.CreatedAt) 77 | 78 | notes = append(notes, *n) 79 | } 80 | 81 | return notes, nil 82 | } 83 | 84 | func (n *Note) GetNoteById() (Note, error) { 85 | db := GetConnection() 86 | 87 | query := `SELECT * FROM notes 88 | WHERE id=?` 89 | 90 | stmt, err := db.Prepare(query) 91 | if err != nil { 92 | return Note{}, err 93 | } 94 | 95 | defer stmt.Close() 96 | 97 | var recoveredNote Note 98 | err = stmt.QueryRow( 99 | n.ID, 100 | ).Scan( 101 | &recoveredNote.ID, 102 | &recoveredNote.Title, 103 | &recoveredNote.Description, 104 | &recoveredNote.Completed, 105 | &recoveredNote.CreatedAt, 106 | ) 107 | if err != nil { 108 | return Note{}, err 109 | } 110 | 111 | return recoveredNote, nil 112 | } 113 | 114 | func (n *Note) UpdateNote() (Note, error) { 115 | db := GetConnection() 116 | 117 | query := `UPDATE notes SET completed=? 118 | WHERE id=? RETURNING *` 119 | 120 | stmt, err := db.Prepare(query) 121 | if err != nil { 122 | return Note{}, err 123 | } 124 | 125 | defer stmt.Close() 126 | 127 | var updatedNote Note 128 | err = stmt.QueryRow( 129 | !n.Completed, 130 | n.ID, 131 | ).Scan( 132 | &updatedNote.ID, 133 | &updatedNote.Title, 134 | &updatedNote.Description, 135 | &updatedNote.Completed, 136 | &updatedNote.CreatedAt, 137 | ) 138 | if err != nil { 139 | return Note{}, err 140 | } 141 | 142 | return updatedNote, nil 143 | } 144 | 145 | func (n *Note) DeleteNote() error { 146 | db := GetConnection() 147 | 148 | query := `DELETE FROM notes 149 | WHERE id=?` 150 | 151 | stmt, err := db.Prepare(query) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | defer stmt.Close() 157 | 158 | result, err := stmt.Exec(n.ID) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | if i, err := result.RowsAffected(); err != nil || i != 1 { 164 | return errors.New("an affected row was expected") 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func convertDateTime(note Note, timeZone string) ConvertedNote { 171 | loc, _ := time.LoadLocation(timeZone) 172 | convertedNote := ConvertedNote{ 173 | ID: note.ID, 174 | Title: note.Title, 175 | Description: note.Description, 176 | Completed: note.Completed, 177 | CreatedAt: note.CreatedAt.In(loc).Format(time.RFC822Z), 178 | } 179 | 180 | return convertedNote 181 | } 182 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var tmpl *template.Template 14 | 15 | /* var funcMap = template.FuncMap{ 16 | "equal": func(n int) bool { return n == 5 }, 17 | "inc": func(n int) int { return n + 1 }, 18 | } */ 19 | 20 | /* templates will be parsed once at package first import */ 21 | func init() { 22 | if tmpl == nil { 23 | if tmpl == nil { 24 | tmpl = template.Must(tmpl.ParseGlob("views/layouts/*.html")) 25 | template.Must(tmpl.ParseGlob("views/*.html")) 26 | template.Must(tmpl.ParseGlob("views/partials/*.html")) 27 | } 28 | } 29 | } 30 | 31 | func ShowHomePage(w http.ResponseWriter, r *http.Request) { 32 | year := time.Now().Year() 33 | 34 | data := map[string]any{ 35 | "Title": "Go & HTMx Demo", 36 | "Year": year, 37 | } 38 | 39 | tmpl.ExecuteTemplate(w, "index.html", data) 40 | } 41 | 42 | func ShowAboutPage(w http.ResponseWriter, r *http.Request) { 43 | year := time.Now().Year() 44 | 45 | data := map[string]any{ 46 | "Title": "About Me | Go & HTMx Demo", 47 | "Year": year, 48 | } 49 | 50 | tmpl.ExecuteTemplate(w, "about.html", data) 51 | } 52 | 53 | func GetNotes(w http.ResponseWriter, r *http.Request) { 54 | // time.Sleep(500 * time.Millisecond) // only to check how the spinner works 55 | 56 | // fmt.Println("Time Zone: ", r.Header.Get("X-TimeZone")) 57 | var intPage int 58 | intPage, _ = strconv.Atoi(r.URL.Query().Get("page")) 59 | if intPage == 0 { 60 | intPage = 1 61 | } 62 | 63 | offset := (intPage - 1) * 5 64 | 65 | note := new(Note) 66 | notesSlice, err := note.GetAllNotes(offset) 67 | if err != nil { 68 | log.Fatalf("something went wrong: %s", err.Error()) 69 | } 70 | 71 | convertedNotes := []ConvertedNote{} 72 | for _, note := range notesSlice { 73 | newConvertedNote := convertDateTime(note, r.Header.Get("X-TimeZone")) 74 | convertedNotes = append(convertedNotes, newConvertedNote) 75 | } 76 | 77 | data := map[string]any{ 78 | "Notes": convertedNotes, 79 | "IncPage": intPage + 1, 80 | "ShowMore": len(convertedNotes) == 5, 81 | } 82 | 83 | tmpl.ExecuteTemplate(w, "note-list", data) 84 | } 85 | 86 | func AddNote(w http.ResponseWriter, r *http.Request) { 87 | 88 | title := strings.Trim(r.PostFormValue("title"), " ") 89 | description := strings.Trim(r.PostFormValue("description"), " ") 90 | if len(title) == 0 || len(description) == 0 { 91 | var errTitle, errDescription string 92 | if len(title) == 0 { 93 | errTitle = "Please enter a title in this field" 94 | } 95 | if len(description) == 0 { 96 | errDescription = "Please enter a description in this field" 97 | } 98 | 99 | data := map[string]string{ 100 | "FormTitle": title, 101 | "FormDescription": description, 102 | "ErrTitle": errTitle, 103 | "ErrDescription": errDescription, 104 | } 105 | 106 | w.Header().Set("HX-Retarget", "form") 107 | w.Header().Set("HX-Reswap", "innerHTML") 108 | tmpl.ExecuteTemplate(w, "new-note-form", data) 109 | 110 | return 111 | } 112 | 113 | newNote := new(Note) 114 | newNote.Title = title 115 | newNote.Description = description 116 | _, err := newNote.CreateNote() 117 | if err != nil { 118 | var message string 119 | 120 | if strings.Contains(err.Error(), "UNIQUE constraint failed") { 121 | message = "The title is already in use 🔥!" 122 | } else if strings.Contains(err.Error(), "CHECK constraint failed") { 123 | message = "The title is longer than 64 characters 🔥!" 124 | } else { 125 | message = fmt.Sprintf("Something went wrong: %s 🔥!", err) 126 | } 127 | 128 | w.Header().Set("HX-Retarget", "body") 129 | w.Header().Set("HX-Reswap", "beforeend") 130 | tmpl.ExecuteTemplate(w, "modal", message) 131 | 132 | return 133 | } 134 | 135 | w.Header().Set("HX-Location", "/") 136 | } 137 | 138 | func CompleteNote(w http.ResponseWriter, r *http.Request) { 139 | id, _ := strconv.Atoi(r.URL.Query().Get("id")) 140 | 141 | note := new(Note) 142 | note.ID = id 143 | recoveredNote, err := note.GetNoteById() 144 | if err != nil { 145 | // w.Header().Set("HX-Trigger", "{\"myEvent\":\"The requested note was not found 😱!\"}") 146 | w.Header().Set("HX-Retarget", "body") 147 | w.Header().Set("HX-Reswap", "beforeend") 148 | tmpl.ExecuteTemplate(w, "modal", "The requested note was not found 😱!") 149 | 150 | return 151 | } 152 | 153 | updatedNote, err := recoveredNote.UpdateNote() 154 | if err != nil { 155 | log.Fatalf("something went wrong: %s", err.Error()) 156 | } 157 | 158 | tmpl.ExecuteTemplate(w, "note-list-element", convertDateTime(updatedNote, r.Header.Get("X-TimeZone"))) 159 | } 160 | 161 | func RemoveNote(w http.ResponseWriter, r *http.Request) { 162 | id, _ := strconv.Atoi(r.URL.Query().Get("id")) 163 | 164 | note := new(Note) 165 | note.ID = id 166 | err := note.DeleteNote() 167 | if err != nil { 168 | w.Header().Set("HX-Retarget", "body") 169 | w.Header().Set("HX-Reswap", "beforeend") 170 | tmpl.ExecuteTemplate(w, "modal", "The requested note was not found 😱!") 171 | 172 | return 173 | } 174 | 175 | w.Header().Set("HX-Location", "/") 176 | } 177 | 178 | /* HOW TO EXTRACT URL QUERY PARAMETERS IN GO. VER: 179 | https://freshman.tech/snippets/go/extract-url-query-params/ 180 | 181 | Parsear parámetros. VER: 182 | https://www.sitepoint.com/get-url-parameters-with-go/ 183 | https://www.golangprograms.com/how-do-you-set-headers-in-an-http-response-in-go.html 184 | 185 | ALTERNATIVE FORM FOR MODAL: 186 | {{ define "modal" }} 187 | 200 | {{ end }} 201 | */ 202 | -------------------------------------------------------------------------------- /static/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 | --------------------------------------------------------------------------------