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

Bad request

error 400

` // 400 + ErrorPageForbidden = `Forbidden | Journal

Forbidden

error 403

` // 403 + ErrorPageNotFound = `Not found | Journal

Not found

error 404

` // 404 + ErrorPageInternalServerError = `Internal server error | Journal

Internal 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] +

+
+
+ + + +
+ [Save] +
+
+
+{{ 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

+
+ +
+

+ [prev] + [next] +

+
+{{ 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

+
+
+ + + +
+ [Save] [Clear] +
+
+
+{{ end }}