diff --git a/.gitignore b/.gitignore index b3ab3c6..e098088 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,7 @@ data journal node_modules test/data/test.db +tests.xml .vscode .DS_Store .history -bootstrap -*.zip -*.pem -!test/cert.pem -!test/key.pem diff --git a/go.mod b/go.mod index e37fdf2..b99d9dd 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,7 @@ require ( golang.org/x/sys v0.33.0 // indirect ) -require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b +require ( + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b + golang.org/x/text v0.25.0 +) diff --git a/internal/app/controller/web/badrequest.go b/internal/app/controller/web/badrequest.go index bcd483c..00334b6 100644 --- a/internal/app/controller/web/badrequest.go +++ b/internal/app/controller/web/badrequest.go @@ -4,6 +4,7 @@ import ( "net/http" "text/template" + "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/pkg/controller" ) @@ -12,15 +13,22 @@ type BadRequest struct { controller.Super } +type badRequestTemplateData struct { + Container interface{} +} + // Run BadRequest func (c *BadRequest) Run(response http.ResponseWriter, request *http.Request) { + data := badRequestTemplateData{} + data.Container = c.Super.Container().(*app.Container) + response.WriteHeader(http.StatusNotFound) c.SaveSession(response) template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", "./web/templates/error.html.tmpl") - template.ExecuteTemplate(response, "layout", c) + template.ExecuteTemplate(response, "layout", data) } // RunBadRequest calls the bad request from an existing controller diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go new file mode 100644 index 0000000..cc2f798 --- /dev/null +++ b/internal/app/controller/web/calendar.go @@ -0,0 +1,138 @@ +package web + +import ( + "log" + "net/http" + "strconv" + "strings" + "text/template" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// Calendar Handle displaying a calendar with blog entries for given days +type Calendar struct { + controller.Super +} + +type day struct { + Date time.Time + IsEmpty bool +} + +type calendarTemplateData struct { + Container interface{} + Days map[int][]model.Journal + Weeks [][]day + CurrentDate time.Time + PrevYear int + PrevYearUrl string + NextYear int + NextYearUrl string + PrevMonth string + PrevMonthUrl string + NextMonth string + NextMonthUrl string +} + +// Run Calendar action +func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { + + data := calendarTemplateData{} + + container := c.Super.Container().(*app.Container) + data.Container = container + js := model.Journals{Container: container} + + // Load date from parameters if available (either 2006/jan or 2006) + date := time.Now() + var err error + if len(c.Params()) == 3 { + date, err = time.Parse("2006 Jan 02", c.Params()[1]+" "+cases.Title(language.English, cases.NoLower).String(c.Params()[2])+" 25") + } else if len(c.Params()) == 2 { + date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") + } + if err != nil { + log.Print(err) + RunBadRequest(response, request, container) + return + } + + firstOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + startWeekday := int(firstOfMonth.Weekday()) + + // Find number of days in month + nextMonth := firstOfMonth.AddDate(0, 1, 0) + lastOfMonth := nextMonth.AddDate(0, 0, -1) + daysInMonth := lastOfMonth.Day() + + data.Days = map[int][]model.Journal{} + data.Weeks = [][]day{} + week := []day{} + + // Fill in blanks before first day + for range startWeekday { + week = append(week, day{IsEmpty: true}) + } + + // Fill in actual days + for d := 1; d <= daysInMonth; d++ { + thisDate := time.Date(date.Year(), date.Month(), d, 0, 0, 0, 0, date.Location()) + data.Days[d] = js.FetchByDate(thisDate.Format("2006-01-02")) + week = append(week, day{ + Date: thisDate, + IsEmpty: false, + }) + + // If Saturday, start a new week + if thisDate.Weekday() == time.Saturday { + data.Weeks = append(data.Weeks, week) + week = []day{} + } + } + + // Fill in blanks after last day + if len(week) > 0 { + for len(week) < 7 { + week = append(week, day{IsEmpty: true}) + } + data.Weeks = append(data.Weeks, week) + } + + // Load prev/next year and month + firstEntry := js.FindNext(0) + firstEntryDate, _ := time.Parse("2006-01-02", firstEntry.GetEditableDate()) + if date.Year() < time.Now().Year() { + data.NextYear = date.Year() + 1 + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(1, 0, 0).After(time.Now()) { + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(time.Now().Format("Jan")) + } + } + if date.Year() > firstEntryDate.Year() { + data.PrevYear = date.Year() - 1 + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(-1, 0, 0).Before(firstEntryDate) { + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(firstEntryDate.Format("Jan")) + } + } + if date.Year() < time.Now().Year() || date.Month() < time.Now().Month() { + data.NextMonth = date.AddDate(0, 0, 31).Format("January") + data.NextMonthUrl = strings.ToLower(date.AddDate(0, 0, 31).Format("2006/Jan")) + } + if date.Year() > firstEntryDate.Year() || date.Month() > firstEntryDate.Month() { + data.PrevMonth = date.AddDate(0, 0, -31).Format("January") + data.PrevMonthUrl = strings.ToLower(date.AddDate(0, 0, -31).Format("2006/Jan")) + } + data.CurrentDate = date + + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/calendar.html.tmpl") + template.ExecuteTemplate(response, "layout", data) +} diff --git a/internal/app/controller/web/calendar_test.go b/internal/app/controller/web/calendar_test.go new file mode 100644 index 0000000..032fb75 --- /dev/null +++ b/internal/app/controller/web/calendar_test.go @@ -0,0 +1,154 @@ +package web + +import ( + "net/http" + "os" + "path" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" +) + +func init() { + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +func TestCalendarRun(t *testing.T) { + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Calendar{} + controller.DisableTracking() + + // Test showing current year/month (only prev nav) + today := time.Now() + firstOfMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) + daysInMonth := firstOfMonth.AddDate(0, 1, -1).Day() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= daysInMonth; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ := http.NewRequest("GET", "/calendar", strings.NewReader("")) + controller.Init(container, []string{}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "class=\"prev prev-year\"") { + t.Error("Expected previous year link to be shown") + } + if !strings.Contains(response.Content, "class=\"prev prev-month\"") { + t.Error("Expected previous month link to be shown") + } + if strings.Contains(response.Content, "class=\"next next-year\"") { + t.Error("Expected next year link to be missing") + } + if strings.Contains(response.Content, "class=\"next next-month\"") { + t.Error("Expected next month link to be missing") + } + + // Test showing beginning (only next nav) + response.Reset() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= 28; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ = http.NewRequest("GET", "/calendar/2018/feb", strings.NewReader("")) + controller.Init(container, []string{"", "2018", "feb"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "

2018

") || !strings.Contains(response.Content, "

February2019

") || !strings.Contains(response.Content, "

January 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journals = js.FetchByDate("2001-01-01") + if len(journals) > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_MultipleRows{} + journals = js.FetchByDate("2001-01-01") + if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { + t.Errorf("Expected 2 rows returned and with correct data") + } +} + func TestJournals_FetchPaginated(t *testing.T) { // Test error diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 170a6ac..8e33bc5 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -17,17 +17,23 @@ func NewRouter(app *app.Container) *pkgrouter.Router { app.Configuration.StaticPath, } - rtr.Get("/sitemap.xml", &web.Sitemap{}) - rtr.Get("/stats", &web.Stats{}) - rtr.Get("/new", &web.New{}) - rtr.Post("/new", &web.New{}) - rtr.Get("/random", &web.Random{}) + // API v1 rtr.Get("/api/v1/stats", &apiv1.Stats{}) rtr.Get("/api/v1/post", &apiv1.List{}) rtr.Put("/api/v1/post", &apiv1.Create{}) rtr.Get("/api/v1/post/random", &apiv1.Random{}) rtr.Get("/api/v1/post/[%s]", &apiv1.Single{}) rtr.Post("/api/v1/post/[%s]", &apiv1.Update{}) + + // Web + rtr.Get("/sitemap.xml", &web.Sitemap{}) + rtr.Get("/stats", &web.Stats{}) + rtr.Get("/new", &web.New{}) + rtr.Post("/new", &web.New{}) + rtr.Get("/random", &web.Random{}) + rtr.Get("/calendar/[%s]/[%s]", &web.Calendar{}) + rtr.Get("/calendar/[%s]", &web.Calendar{}) + rtr.Get("/calendar", &web.Calendar{}) rtr.Get("/[%s]/edit", &web.Edit{}) rtr.Post("/[%s]/edit", &web.Edit{}) rtr.Get("/[%s]", &web.View{}) diff --git a/web/templates/_layout/default.html.tmpl b/web/templates/_layout/default.html.tmpl index 97365fb..ad31246 100644 --- a/web/templates/_layout/default.html.tmpl +++ b/web/templates/_layout/default.html.tmpl @@ -16,9 +16,10 @@
{{.Container.Configuration.Title}}
diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl new file mode 100644 index 0000000..3d100f8 --- /dev/null +++ b/web/templates/calendar.html.tmpl @@ -0,0 +1,58 @@ +{{define "title"}}Calendar - {{end}} +{{define "content"}} + +
+ + + + + + + + + + + + + + + + + {{range .Weeks}} + + {{range .}} + {{if .IsEmpty}} + + {{else}} + + {{end}} + {{end}} + + {{end}} + +
SunMonTueWedThuFriSat
  +

{{.Date.Day}}

+ {{range (index $.Days .Date.Day)}} + {{.Title}} + {{end}} +
+
+ + +{{end}} diff --git a/web/themes/default/style.css b/web/themes/default/style.css index dc87106..718d9e8 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -85,7 +85,12 @@ header[role=banner] p { padding-top: .5rem; } -#menu .button { +#menu{ + padding-top: .375rem; +} + +#menu a { + font-size: 1rem; margin-left: 15px; } @@ -542,3 +547,88 @@ section.stats { .visits th:nth-child(4) { text-align: right; } + +.calendar-top { + clear: both; + position: relative; + text-align: center; +} + +.calendar-top h2 { + margin: 0 auto 2rem; +} + +.calendar-top .prev { + left: 0; + position: absolute; + text-align: left; + top: .5rem; +} + +.calendar-top .next { + position: absolute; + right: 0; + top: .5rem; + text-align: right; +} + +.calendar { + border: 2px solid #111; + border-collapse: collapse; + margin-top: 4rem; + width: 100%; +} + +.calendar thead { + display: none; +} + +.calendar th, +.calendar td { + border: 1px solid #dedede; + padding: .25rem .5rem; + vertical-align: top; +} + +.calendar th { + background-color: #dedede; + font-weight: bold; + text-align: right; +} + +.calendar td { + display: block; + padding: 1.75rem 1rem .5rem; + position: relative; +} + +.calendar td.empty { + display: none; +} + +.calendar td h3 { + font-size: 1.25rem; + margin: 0; + position: absolute; + right: .5rem; + text-align: right; + top: .5rem; +} + +@media screen and (min-width: 768px) { + .calendar thead { + display: table-header-group; + } + + .calendar td.empty, .calendar td { + display: table-cell; + height: 3rem; + padding: 1.75rem .5rem .5rem; + width: 14.27%; + } + + .calendar td a { + font-size: 1rem; + line-height: 1.25rem; + } +}