feat: add app code

- journal domain package
- httpserver package
- html templates
- main.go in root dir
This commit is contained in:
Vojtěch Mareš 2025-04-22 22:01:53 +02:00
parent 3cc4d28aac
commit 943922a6e1
Signed by: vojtech.mares
GPG key ID: C6827B976F17240D
20 changed files with 1032 additions and 0 deletions

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
assets/journal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

12
go.mod
View file

@ -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
)

12
go.sum Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
}

View 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
View 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
View 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 }}&nbsp[{{ .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
View 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
View 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
View 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>]&nbsp;[<a href="#" onclick="clearForm(event)">Clear</a>]
</div>
</form>
</main>
{{ end }}