diff --git a/assets/favicon.ico b/assets/favicon.ico
new file mode 100644
index 0000000..40d39a4
Binary files /dev/null and b/assets/favicon.ico differ
diff --git a/assets/journal.png b/assets/journal.png
new file mode 100644
index 0000000..d9e5fb5
Binary files /dev/null and b/assets/journal.png differ
diff --git a/go.mod b/go.mod
index 8ed39c0..b5e599b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,15 @@
module gitlab.mareshq.com/lab/journal
go 1.24.2
+
+require (
+ gorm.io/driver/sqlite v1.5.7
+ gorm.io/gorm v1.25.12
+)
+
+require (
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
+ golang.org/x/text v0.24.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7253b42
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
+gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
diff --git a/httpserver/errors.go b/httpserver/errors.go
new file mode 100644
index 0000000..3494619
--- /dev/null
+++ b/httpserver/errors.go
@@ -0,0 +1,34 @@
+package httpserver
+
+import "net/http"
+
+var (
+ ErrorPageBadRequest = `
Bad request | Journal` // 400
+ ErrorPageForbidden = `Forbidden | Journal` // 403
+ ErrorPageNotFound = `Not found | Journal` // 404
+ ErrorPageInternalServerError = `Internal server error | JournalInternal server error
error 500
` // 500
+)
+
+func BadRequest(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(ErrorPageBadRequest))
+}
+
+func Forbidden(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(ErrorPageForbidden))
+}
+
+func NotFound(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(ErrorPageNotFound))
+}
+
+func InternalServerError(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(ErrorPageInternalServerError))
+}
diff --git a/httpserver/favicon.go b/httpserver/favicon.go
new file mode 100644
index 0000000..0bbd2f1
--- /dev/null
+++ b/httpserver/favicon.go
@@ -0,0 +1,18 @@
+package httpserver
+
+import "net/http"
+
+type Favicon struct {
+ favicon []byte
+}
+
+func NewFavicon(favicon []byte) *Favicon {
+ return &Favicon{
+ favicon: favicon,
+ }
+}
+
+func (f *Favicon) FaviconHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/x-icon")
+ w.Write(f.favicon)
+}
diff --git a/httpserver/middleware.go b/httpserver/middleware.go
new file mode 100644
index 0000000..8e7038f
--- /dev/null
+++ b/httpserver/middleware.go
@@ -0,0 +1,74 @@
+package httpserver
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "time"
+)
+
+type Middleware http.HandlerFunc
+
+func Authenticated(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Perform authentication here
+ // For example, check for a valid token in the request header
+ token := r.Header.Get("Authorization")
+ if token == "" {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // If authentication is successful, call the next handler
+ next.ServeHTTP(w, r)
+ })
+}
+
+type LoggingMiddleware struct {
+ logger *log.Logger
+}
+
+func NewLoggingMiddleware(logger *log.Logger) *LoggingMiddleware {
+ return &LoggingMiddleware{logger: logger}
+}
+
+func (m *LoggingMiddleware) Logging(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ m.logger.Println("Request:", r.Method, r.URL.Path, "Duration:", time.Since(start))
+ })
+}
+
+type Timeout struct {
+ Timeout time.Duration
+}
+
+func (m *Timeout) TimeoutMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), m.Timeout)
+ defer cancel()
+
+ r = r.WithContext(ctx)
+
+ ch := make(chan struct{})
+ defer close(ch)
+ //
+ go func() {
+ next.ServeHTTP(w, r)
+ ch <- struct{}{}
+ }()
+
+ select {
+ case <-ch:
+ // Request completed within timeout
+ case <-ctx.Done():
+ // Request timed out
+ http.Error(w, "Request timed out", http.StatusGatewayTimeout)
+ }
+ })
+}
+
+func NewTimeout(timeout time.Duration) *Timeout {
+ return &Timeout{Timeout: timeout}
+}
diff --git a/httpserver/renderer.go b/httpserver/renderer.go
new file mode 100644
index 0000000..3fdf772
--- /dev/null
+++ b/httpserver/renderer.go
@@ -0,0 +1,39 @@
+package httpserver
+
+import (
+ "fmt"
+ "html/template"
+ "io/fs"
+ "log"
+ "net/http"
+)
+
+type TemplateRenderer struct {
+ templatesFS fs.FS
+}
+
+func NewTemplateRenderer(templatesFS fs.FS) *TemplateRenderer {
+ return &TemplateRenderer{
+ templatesFS: templatesFS,
+ }
+}
+
+func (r *TemplateRenderer) Render(w http.ResponseWriter, templatePath string, data interface{}) {
+ tmpl, err := template.ParseFS(r.templatesFS, fmt.Sprintf("templates/%s", templatePath), "templates/layout.html")
+ if err != nil {
+ InternalServerError(w, nil)
+ log.Printf("failed to parse template %s: %v", templatePath, err)
+
+ return
+ }
+
+ err = tmpl.Execute(w, data)
+ if err != nil {
+ InternalServerError(w, nil)
+ log.Printf("failed to execute template %s: %v", templatePath, err)
+
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+}
diff --git a/journal/handlers.go b/journal/handlers.go
new file mode 100644
index 0000000..8b9a684
--- /dev/null
+++ b/journal/handlers.go
@@ -0,0 +1,305 @@
+package journal
+
+import (
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "log"
+ "net/http"
+ "strconv"
+ "time"
+
+ "gitlab.mareshq.com/lab/journal/httpserver"
+ "gorm.io/gorm"
+)
+
+const dateFormat = "2006-01-02"
+
+type httpEntry struct {
+ ID int
+ Title string
+ FormattedDate string
+ Content string
+ HTMLContent template.HTML
+}
+
+func httpEntryFromEntry(entry Entry) httpEntry {
+ return httpEntry{
+ ID: entry.ID,
+ Title: entry.Title,
+ FormattedDate: entry.Date.Format(dateFormat),
+ Content: html.UnescapeString(entry.Content),
+ HTMLContent: renderContent(entry.Content),
+ }
+}
+
+type Server struct {
+ repo *Repository
+ logger *log.Logger
+ templateRenderer *httpserver.TemplateRenderer
+}
+
+func NewServer(repo *Repository, templateRenderer *httpserver.TemplateRenderer, logger *log.Logger) *Server {
+ return &Server{
+ repo: repo,
+ logger: logger,
+ templateRenderer: templateRenderer,
+ }
+}
+
+func (s *Server) HandleEntries(w http.ResponseWriter, r *http.Request) {
+ type templateProperties struct {
+ Entries []httpEntry
+ Pagination struct {
+ Next int
+ Prev int
+ }
+ }
+
+ limit := 24
+
+ var offsetID *int = nil
+ strOffset := r.URL.Query().Get("offset")
+
+ if strOffset != "" {
+ parsedOffset, err := strconv.Atoi(strOffset)
+ if err == nil && parsedOffset > 0 {
+ offsetID = &parsedOffset
+ }
+ }
+
+ entriesFromDB, err := s.repo.Find(r.Context(), limit, offsetID)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ httpserver.NotFound(w, r)
+ s.logger.Println("No entries found:", err)
+
+ return
+ }
+
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ s.logger.Println("Error finding entries:", err)
+
+ return
+ }
+
+ prev := 0
+ next := 0
+
+ if offsetID != nil {
+ if *offsetID > 0 {
+ prev = *offsetID - limit
+ }
+ if len(entriesFromDB) == limit {
+ next = *offsetID + limit
+ }
+ } else {
+ if len(entriesFromDB) == limit {
+ next = limit
+ }
+ if len(entriesFromDB) > 0 {
+ prev = entriesFromDB[0].ID - limit
+ }
+ }
+
+ if len(entriesFromDB) == limit {
+ next = entriesFromDB[len(entriesFromDB)-1].ID
+ }
+
+ if offsetID != nil {
+ prev = *offsetID - limit // no fancy DB pagination, just simple/naive cursor
+ }
+
+ // normalize offset IDs
+ if prev < 0 {
+ prev = 0
+ }
+ if next < 0 {
+ next = 0
+ }
+
+ tplProps := templateProperties{
+ Entries: make([]httpEntry, len(entriesFromDB)),
+ Pagination: struct {
+ Next int
+ Prev int
+ }{
+ Next: next,
+ Prev: prev,
+ },
+ }
+
+ for i, entry := range entriesFromDB {
+ tplProps.Entries[i] = httpEntryFromEntry(*entry)
+ }
+
+ s.templateRenderer.Render(w, "entries.html", tplProps)
+}
+
+func (s *Server) HandleEntryByID(w http.ResponseWriter, r *http.Request) {
+ strID := r.PathValue("id")
+ id, err := strconv.Atoi(strID)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ log.Println("Error converting ID to int:", err)
+ return
+ }
+
+ entryFromDB, err := s.repo.FindByID(r.Context(), id)
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ log.Println("Error finding entry by ID:", err)
+
+ return
+ }
+
+ entry := httpEntryFromEntry(*entryFromDB)
+
+ s.templateRenderer.Render(w, "entry.html", entry)
+}
+
+func (s *Server) HandleDeleteEntryByID(w http.ResponseWriter, r *http.Request) {
+ strID := r.PostFormValue("id")
+
+ s.logger.Println("Deleting entry with ID:", strID)
+
+ id, err := strconv.Atoi(strID)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ s.logger.Printf("Error converting ID to int: %v, got: %v\n", err, strID)
+
+ return
+ }
+
+ err = s.repo.Delete(r.Context(), id)
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ s.logger.Println("Error deleting entry by ID:", err)
+
+ return
+ }
+
+ http.Redirect(w, r, "/entry/deleted", http.StatusSeeOther)
+}
+
+func (s *Server) HandleNewEntry(w http.ResponseWriter, r *http.Request) {
+ templateProps := struct {
+ Today string
+ }{
+ Today: time.Now().Format(dateFormat),
+ }
+
+ s.templateRenderer.Render(w, "new-entry.html", templateProps)
+}
+
+func (s *Server) HandleSubmitNewEntry(w http.ResponseWriter, r *http.Request) {
+ title := r.PostFormValue("title")
+ date := r.PostFormValue("date")
+ content := r.PostFormValue("content")
+
+ if title == "" || date == "" || content == "" {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Missing required fields")
+
+ return
+ }
+
+ parsedDate, err := time.Parse(dateFormat, date)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Error parsing date:", err)
+
+ return
+ }
+
+ e := &Entry{
+ Title: title,
+ Date: parsedDate,
+ Content: content,
+ }
+
+ err = s.repo.Create(r.Context(), e)
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ s.logger.Println("Error creating entry:", err)
+
+ return
+ }
+
+ http.Redirect(w, r, fmt.Sprintf("/entry/%d", e.ID), http.StatusSeeOther)
+}
+
+func (s *Server) HandleDeletedEntry(w http.ResponseWriter, r *http.Request) {
+ s.templateRenderer.Render(w, "deleted-entry.html", nil)
+}
+
+func (s *Server) HandleEditEntryByID(w http.ResponseWriter, r *http.Request) {
+ strID := r.PathValue("id")
+ id, err := strconv.Atoi(strID)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Error converting ID to int:", err)
+
+ return
+ }
+
+ entryFromDB, err := s.repo.FindByID(r.Context(), id)
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ s.logger.Println("Error finding entry by ID:", err)
+
+ return
+ }
+
+ entry := httpEntryFromEntry(*entryFromDB)
+
+ s.templateRenderer.Render(w, "edit-entry.html", entry)
+}
+
+func (s *Server) HandleSubmitEditEntryByID(w http.ResponseWriter, r *http.Request) {
+ strID := r.PathValue("id")
+ id, err := strconv.Atoi(strID)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Error converting ID to int:", err)
+
+ return
+ }
+
+ title := r.PostFormValue("title")
+ date := r.PostFormValue("date")
+ content := r.PostFormValue("content")
+
+ if title == "" || date == "" || content == "" {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Missing required fields")
+
+ return
+ }
+
+ parsedDate, err := time.Parse(dateFormat, date)
+ if err != nil {
+ httpserver.BadRequest(w, r)
+ s.logger.Println("Error parsing date:", err)
+
+ return
+ }
+
+ e := &Entry{
+ ID: id,
+ Title: title,
+ Date: parsedDate,
+ Content: content,
+ }
+
+ err = s.repo.Update(r.Context(), e)
+ if err != nil {
+ httpserver.InternalServerError(w, r)
+ s.logger.Println("Error updating entry:", err)
+
+ return
+ }
+
+ http.Redirect(w, r, fmt.Sprintf("/entry/%d", e.ID), http.StatusSeeOther)
+}
diff --git a/journal/model.go b/journal/model.go
new file mode 100644
index 0000000..f49a97a
--- /dev/null
+++ b/journal/model.go
@@ -0,0 +1,20 @@
+package journal
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type Entry struct {
+ ID int `gorm:"primaryKey"`
+ Date time.Time
+ Title string
+ Content string
+
+ gorm.DeletedAt `gorm:"index,default:null"`
+}
+
+func (e *Entry) TableName() string {
+ return "entries"
+}
diff --git a/journal/renderer.go b/journal/renderer.go
new file mode 100644
index 0000000..7890cdc
--- /dev/null
+++ b/journal/renderer.go
@@ -0,0 +1,10 @@
+package journal
+
+import (
+ "html/template"
+ "strings"
+)
+
+func renderContent(content string) template.HTML {
+ return template.HTML(strings.ReplaceAll(content, "\n", "
"))
+}
diff --git a/journal/repository.go b/journal/repository.go
new file mode 100644
index 0000000..3658932
--- /dev/null
+++ b/journal/repository.go
@@ -0,0 +1,103 @@
+package journal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+
+ "gorm.io/gorm"
+)
+
+type Repository struct {
+ db *gorm.DB
+}
+
+func NewRepository(db *gorm.DB) *Repository {
+ return &Repository{db: db}
+}
+
+func (r *Repository) Create(ctx context.Context, entry *Entry) error {
+ if entry == nil {
+ return fmt.Errorf("entry cannot be nil")
+ }
+
+ sanitizedContent := sanitizeContent(entry.Content)
+ entry.Content = sanitizedContent
+
+ err := r.db.WithContext(ctx).Create(entry).Error
+ if err != nil {
+ return fmt.Errorf("failed to create entry: %w", err)
+ }
+
+ return nil
+}
+
+func (r *Repository) FindByID(ctx context.Context, id int) (*Entry, error) {
+ var entry Entry
+ err := r.db.WithContext(ctx).Where("id = ?", id).First(&entry).Error
+ if err != nil {
+ return nil, fmt.Errorf("failed to find entry: %w", err)
+ }
+
+ return &entry, nil
+}
+
+func (r *Repository) Find(ctx context.Context, limit int, offsetID *int) ([]*Entry, error) {
+ var entries []*Entry
+
+ if offsetID == nil {
+ err := r.db.WithContext(ctx).Limit(limit).Order("date DESC, id DESC").Find(&entries).Error
+ if err != nil {
+ return nil, errors.Join(errors.New("failed to find entries"), err)
+ }
+
+ return entries, nil
+ }
+
+ if *offsetID < 0 {
+ return nil, fmt.Errorf("offsetID must be greater than or equal to 0, %d given", *offsetID)
+ }
+
+ if limit <= 0 {
+ return nil, fmt.Errorf("limit must be greater than 0")
+ }
+
+ err := r.db.WithContext(ctx).Where("id > ?", offsetID).Limit(limit).Order("id ASC").Find(&entries).Error
+ if err != nil {
+ return nil, fmt.Errorf("failed to find entries: %w", err)
+ }
+
+ return entries, nil
+}
+
+func (r *Repository) Update(ctx context.Context, entry *Entry) error {
+ if entry == nil {
+ return fmt.Errorf("entry cannot be nil")
+ }
+
+ sanitizedContent := sanitizeContent(entry.Content)
+ entry.Content = sanitizedContent
+
+ err := r.db.WithContext(ctx).Save(entry).Error
+ if err != nil {
+ return fmt.Errorf("failed to update entry: %w", err)
+ }
+
+ return nil
+}
+
+func (r *Repository) Delete(ctx context.Context, id int) error {
+ err := r.db.WithContext(ctx).Delete(&Entry{}, id).Error
+ if err != nil {
+ return fmt.Errorf("failed to delete entry: %w", err)
+ }
+
+ return nil
+}
+
+func sanitizeContent(content string) string {
+ // Implement your sanitization logic here
+ // For example, you can use a library like "github.com/microcosm-cc/bluemonday" to sanitize HTML content
+ return html.EscapeString(content)
+}
diff --git a/journal/routes.go b/journal/routes.go
new file mode 100644
index 0000000..4adfd48
--- /dev/null
+++ b/journal/routes.go
@@ -0,0 +1,14 @@
+package journal
+
+import "net/http"
+
+func (s *Server) RegisterRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /", s.HandleEntries)
+ mux.HandleFunc("GET /entry/new", s.HandleNewEntry)
+ mux.HandleFunc("POST /entry/new", s.HandleSubmitNewEntry)
+ mux.HandleFunc("POST /entry/delete", s.HandleDeleteEntryByID)
+ mux.HandleFunc("GET /entry/{id}", s.HandleEntryByID)
+ mux.HandleFunc("GET /entry/{id}/edit", s.HandleEditEntryByID)
+ mux.HandleFunc("POST /entry/{id}/edit", s.HandleSubmitEditEntryByID)
+ mux.HandleFunc("GET /entry/deleted", s.HandleDeletedEntry)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3be923d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "gitlab.mareshq.com/lab/journal/httpserver"
+ "gitlab.mareshq.com/lab/journal/journal"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+//go:embed assets/favicon.ico
+var favicon []byte
+
+//go:embed templates/*
+var templatesFS embed.FS
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ logger := log.New(os.Stdout, "", log.LstdFlags)
+
+ db, err := gorm.Open(sqlite.Open("var/local.db"), &gorm.Config{})
+ if err != nil {
+ logger.Fatalf("failed to connect database: %v", err)
+ }
+
+ sqlDB, err := db.DB()
+ if err != nil {
+ logger.Fatalf("failed to get database: %v", err)
+ }
+
+ // graceful shutdown of database connections
+ defer func() {
+ err = sqlDB.Close()
+ if err != nil {
+ logger.Fatalf("failed to close database: %v", err)
+ }
+ }()
+
+ err = db.AutoMigrate(&journal.Entry{})
+ if err != nil {
+ logger.Fatalf("failed to migrate database: %v\n", err)
+ }
+
+ repo := journal.NewRepository(db)
+ tr := httpserver.NewTemplateRenderer(templatesFS)
+
+ mux := http.NewServeMux()
+
+ fav := httpserver.NewFavicon(favicon)
+ mux.HandleFunc("GET /favicon.ico", fav.FaviconHandler)
+
+ // Journal server is HTTP interface
+ journalServer := journal.NewServer(repo, tr, logger)
+ journalServer.RegisterRoutes(mux)
+
+ loggingMiddleware := httpserver.NewLoggingMiddleware(logger)
+
+ server := &http.Server{
+ Addr: ":8080",
+ Handler: loggingMiddleware.Logging(mux),
+ }
+
+ // start the HTTP server in a goroutine
+ go func() {
+ logger.Printf("Starting HTTP server on %s\n", server.Addr)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.Fatalf("listen: %s\n", err)
+ }
+ }()
+
+ <-ctx.Done()
+ logger.Println("Received signal, shutting down...")
+
+ ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := server.Shutdown(ctxShutdown); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ logger.Fatalf("HTTP Server forced to shutdown: %v", err)
+ }
+
+ logger.Println("HTTP server shut down")
+}
diff --git a/templates/deleted-entry.html b/templates/deleted-entry.html
new file mode 100644
index 0000000..b7a7779
--- /dev/null
+++ b/templates/deleted-entry.html
@@ -0,0 +1,16 @@
+{{ template "layout.html" . }}
+
+{{ define "title" }}Entry deleted{{ end }}
+
+{{ define "content" }}
+
+
+ Entry has been deleted.
+
+{{ end }}
diff --git a/templates/edit-entry.html b/templates/edit-entry.html
new file mode 100644
index 0000000..6c69ace
--- /dev/null
+++ b/templates/edit-entry.html
@@ -0,0 +1,61 @@
+{{ template "layout.html" . }}
+
+{{ define "title" }}Edit entry: {{ .Title }}{{ end }}
+
+{{ define "content" }}
+
+
+
+ Edit entry: {{ .Title }}
+
+
+ [Back to entry]
+
+
+
+
+{{ end }}
diff --git a/templates/entries.html b/templates/entries.html
new file mode 100644
index 0000000..9c1160f
--- /dev/null
+++ b/templates/entries.html
@@ -0,0 +1,29 @@
+{{ template "layout.html" . }}
+
+{{ define "title" }}Home{{ end }}
+
+{{ define "content" }}
+
+
+ Journal entries
+
+
+
+
+ []
+ []
+
+
+{{ end }}
diff --git a/templates/entry.html b/templates/entry.html
new file mode 100644
index 0000000..26d0fc2
--- /dev/null
+++ b/templates/entry.html
@@ -0,0 +1,28 @@
+{{ template "layout.html" . }}
+
+{{ define "title" }}{{ .Title }} [{{ .FormattedDate }}]{{ end }}
+
+{{ define "content" }}
+
+
+ {{ .Title }}
+ [{{ .FormattedDate }}]
+
+
+ [Edit]
+ [Delete]
+
+
+
+ {{ .HTMLContent }}
+
+{{ end }}
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..1819bf2
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {{ block "title" .}}{{ end }} | Journal
+
+
+
+
+
+ {{ block "content" . }}{{ end }}
+
+
diff --git a/templates/new-entry.html b/templates/new-entry.html
new file mode 100644
index 0000000..4027762
--- /dev/null
+++ b/templates/new-entry.html
@@ -0,0 +1,74 @@
+{{ template "layout.html" . }}
+
+{{ define "title" }}New entry{{ end }}
+
+{{ define "content" }}
+
+
+
+ New entry
+
+
+
+{{ end }}