feat: add app code
- journal domain package - httpserver package - html templates - main.go in root dir
This commit is contained in:
parent
3cc4d28aac
commit
943922a6e1
20 changed files with 1032 additions and 0 deletions
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
assets/journal.png
Normal file
BIN
assets/journal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
12
go.mod
12
go.mod
|
|
@ -1,3 +1,15 @@
|
||||||
module gitlab.mareshq.com/lab/journal
|
module gitlab.mareshq.com/lab/journal
|
||||||
|
|
||||||
go 1.24.2
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -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=
|
||||||
34
httpserver/errors.go
Normal file
34
httpserver/errors.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrorPageBadRequest = `<!DOCTYPE html><html><head><title>Bad request | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Bad request</h1><p>error 400</p></div></body></html>` // 400
|
||||||
|
ErrorPageForbidden = `<!DOCTYPE html><html><head><title>Forbidden | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Forbidden</h1><p>error 403</p></div></body></html>` // 403
|
||||||
|
ErrorPageNotFound = `<!DOCTYPE html><html><head><title>Not found | Journal</title><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style></head><body><div id="error"><h1>Not found</h1><p>error 404</p></div></body></html>` // 404
|
||||||
|
ErrorPageInternalServerError = `<!DOCTYPE html><html><head><title>Internal server error | Journal</title></head><style>html { font-family: Consolas, monospace; } #error { margin: 0 auto; margin-top: 8rem; max-width: 40rem; } h1 { font-size: 3rem; } p { font-size: 1.5rem; margin-top: -1rem; }</style><body><div id="error"><h1>Internal server error</h1><p>error 500</p></div></body></html>` // 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))
|
||||||
|
}
|
||||||
18
httpserver/favicon.go
Normal file
18
httpserver/favicon.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
74
httpserver/middleware.go
Normal file
74
httpserver/middleware.go
Normal file
|
|
@ -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}
|
||||||
|
}
|
||||||
39
httpserver/renderer.go
Normal file
39
httpserver/renderer.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
305
journal/handlers.go
Normal file
305
journal/handlers.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
20
journal/model.go
Normal file
20
journal/model.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
10
journal/renderer.go
Normal file
10
journal/renderer.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderContent(content string) template.HTML {
|
||||||
|
return template.HTML(strings.ReplaceAll(content, "\n", "<br />"))
|
||||||
|
}
|
||||||
103
journal/repository.go
Normal file
103
journal/repository.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
14
journal/routes.go
Normal file
14
journal/routes.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
92
main.go
Normal file
92
main.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
16
templates/deleted-entry.html
Normal file
16
templates/deleted-entry.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{{ template "layout.html" . }}
|
||||||
|
|
||||||
|
{{ define "title" }}Entry deleted{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<main class="container">
|
||||||
|
<p>Entry has been deleted.</p>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
61
templates/edit-entry.html
Normal file
61
templates/edit-entry.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{{ template "layout.html" . }}
|
||||||
|
|
||||||
|
{{ define "title" }}Edit entry: {{ .Title }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* max-width: 600px; */
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
form input,
|
||||||
|
form textarea {
|
||||||
|
font-size: 1rem;
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
form input[type="date"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
form input[type="text"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
form textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
resize: none;
|
||||||
|
height: 24rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function editEntry(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById('edit-entry').submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<main class="container">
|
||||||
|
<h1>Edit entry: {{ .Title }}</h1>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
[<a href="/entry/{{ .ID }}">Back to entry</a>]
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<form id="edit-entry" action="/entry/{{ .ID }}/edit" method="POST">
|
||||||
|
<input type="text" name="title" placeholder="Title" value="{{ .Title }}" required>
|
||||||
|
<input type="date" name="date" value="{{ .FormattedDate }}" required>
|
||||||
|
<textarea name="content" placeholder="Content" rows="12" required>{{ .Content }}</textarea>
|
||||||
|
<div>
|
||||||
|
[<a href="#" onclick="editEntry(event)">Save</a>]
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
29
templates/entries.html
Normal file
29
templates/entries.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{{ template "layout.html" . }}
|
||||||
|
|
||||||
|
{{ define "title" }}Home{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
.disabled {
|
||||||
|
color: #aaa !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<main class="container">
|
||||||
|
<h1>Journal entries</h1>
|
||||||
|
<hr />
|
||||||
|
<ul>
|
||||||
|
<!-- Entry title [date | YYYY-MM-DD] -->
|
||||||
|
{{ range .Entries }} <!-- begin range -->
|
||||||
|
<li>
|
||||||
|
<a href="/entry/{{ .ID }}">{{ .Title }} [{{ .FormattedDate }}]</a>
|
||||||
|
</li>
|
||||||
|
{{ end }} <!-- end range -->
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
[<a href="/?offset={{ .Pagination.Prev }}" class="{{ if eq .Pagination.Prev 0 }}disabled{{ end }}">prev</a>]
|
||||||
|
[<a href="/?offset={{ .Pagination.Next }}" class="{{ if eq .Pagination.Next 0 }}disabled{{ end }}">next</a>]
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
28
templates/entry.html
Normal file
28
templates/entry.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{{ template "layout.html" . }}
|
||||||
|
|
||||||
|
{{ define "title" }}{{ .Title }} [{{ .FormattedDate }}]{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<script>
|
||||||
|
function deleteEntry(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (window.confirm('Do you really want to delete this entry?')) {
|
||||||
|
document.getElementById('delete-entry').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<h2>[{{ .FormattedDate }}]</h2>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
[<a href="/entry/{{ .ID }}/edit">Edit</a>]
|
||||||
|
[<a href="#" onclick="deleteEntry(event)">Delete</a>]
|
||||||
|
</p>
|
||||||
|
<form id="delete-entry" action="/entry/delete" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
<p>{{ .HTMLContent }}</p>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
91
templates/layout.html
Normal file
91
templates/layout.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ block "title" .}}{{ end }} | Journal</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<style>
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
max-width: 64rem;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
nav ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
nav ul li {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
nav ul li a {
|
||||||
|
color: #007BFF;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #007BFF;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 768px;
|
||||||
|
margin: 0 auto;
|
||||||
|
/* padding: 1rem; */
|
||||||
|
padding-top: .5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
border-color: #000;
|
||||||
|
border-width: 0.25rem;
|
||||||
|
border-style: solid;
|
||||||
|
background-color: #eeeeee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Journal</strong></li>
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/entry/new">New entry</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{{ block "content" . }}{{ end }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
templates/new-entry.html
Normal file
74
templates/new-entry.html
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
{{ template "layout.html" . }}
|
||||||
|
|
||||||
|
{{ define "title" }}New entry{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* max-width: 600px; */
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
form input,
|
||||||
|
form textarea {
|
||||||
|
font-size: 1rem;
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
form input[type="date"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
form input[type="text"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
form textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
resize: none;
|
||||||
|
height: 24rem;
|
||||||
|
}
|
||||||
|
/* form button {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background-color: #000;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
form button:hover {
|
||||||
|
background-color: #333;
|
||||||
|
} */
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function saveEntry(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById('new-entry').submit();
|
||||||
|
}
|
||||||
|
function clearForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById('new-entry').reset();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<main class="container">
|
||||||
|
<h1>New entry</h1>
|
||||||
|
<hr />
|
||||||
|
<form id="new-entry" action="/entry/new" method="POST">
|
||||||
|
<input type="text" name="title" placeholder="Title" required>
|
||||||
|
<input type="date" name="date" value="{{ .Today }}" required>
|
||||||
|
<textarea name="content" placeholder="Content" rows="12" required></textarea>
|
||||||
|
<div>
|
||||||
|
[<a href="#" onclick="saveEntry(event)">Save</a>] [<a href="#" onclick="clearForm(event)">Clear</a>]
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue