diff --git a/assets/diary/diary-calendar.html b/assets/diary/diary-calendar.html new file mode 100644 index 0000000..7a1cd41 --- /dev/null +++ b/assets/diary/diary-calendar.html @@ -0,0 +1,28 @@ +
+
Chronological
+
+ {{.MonthName}} + + {{.DisplayYear}} + +
+ + + + + + {{range .Weeks}}{{range .}}{{end}} + {{end}} + +
MoDiMiDoFrSaSo
{{if .Num}}{{.Num}}{{end}}
+
diff --git a/assets/diary/diary-calendar.js b/assets/diary/diary-calendar.js new file mode 100644 index 0000000..075e3d6 --- /dev/null +++ b/assets/diary/diary-calendar.js @@ -0,0 +1,60 @@ +(function () { + var cal = document.querySelector(".diary-cal"); + if (!cal) return; + + var toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "diary-cal-toggle"; + toggle.textContent = "Kalender"; + toggle.setAttribute("aria-expanded", "false"); + toggle.addEventListener("click", function () { + var open = cal.classList.toggle("is-open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + + var main = document.querySelector("main"); + if (main) { + main.parentNode.insertBefore(toggle, main); + main.parentNode.insertBefore(cal, main); + } + + // Wire up month/year dropdowns inside the calendar. + var drops = cal.querySelectorAll(".diary-cal-drop"); + drops.forEach(function (drop) { + var trigger = drop.querySelector("button"); + var menu = drop.querySelector(".dropdown-menu"); + if (!trigger || !menu) return; + trigger.addEventListener("click", function (e) { + e.stopPropagation(); + drops.forEach(function (other) { + if (other !== drop) { + other.querySelector(".dropdown-menu").classList.remove("is-open"); + } + }); + menu.classList.toggle("is-open"); + }); + }); + document.addEventListener("click", function (e) { + drops.forEach(function (drop) { + if (!drop.contains(e.target)) { + drop.querySelector(".dropdown-menu").classList.remove("is-open"); + } + }); + }); + document.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + drops.forEach(function (drop) { + drop.querySelector(".dropdown-menu").classList.remove("is-open"); + }); + }); + + var pageHeader = document.querySelector("header"); + function updateTop() { + if (!pageHeader || getComputedStyle(cal).position !== "fixed") return; + var rect = pageHeader.getBoundingClientRect(); + cal.style.top = Math.max(8, rect.bottom + 8) + "px"; + } + window.addEventListener("scroll", updateTop, { passive: true }); + window.addEventListener("resize", updateTop); + updateTop(); +})(); diff --git a/assets/page.html b/assets/page.html index 7520d54..5ee64b1 100644 --- a/assets/page.html +++ b/assets/page.html @@ -99,5 +99,9 @@ {{end}} {{end}} + {{if .DiaryWidget}} + {{.DiaryWidget}} + + {{end}} diff --git a/assets/style.css b/assets/style.css index f50b19d..2426a42 100644 --- a/assets/style.css +++ b/assets/style.css @@ -516,6 +516,140 @@ hr { outline: none; } +/* === Diary Calendar === */ +.diary-cal { + position: fixed; + top: 1rem; + left: 1rem; + width: 13rem; + border: 1px solid var(--secondary); + background: var(--bg); + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +.diary-cal-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border-bottom: 1px dashed var(--secondary); + padding-bottom: 0.25rem; + margin-bottom: 0.4rem; +} + +.diary-cal-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0.2rem; + margin-bottom: 0.4rem; + font-size: 0.85rem; +} + +.diary-cal-nav .diary-cal-drop + .diary-cal-heading { + margin-left: 0.75rem; +} + +.diary-cal-heading { + color: var(--link); +} + +.diary-cal-heading:hover { + color: var(--link-hover); +} + +.diary-cal-grid { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + margin-bottom: 0.4rem; +} + +.diary-cal-grid th, +.diary-cal-grid td { + text-align: center; + padding: 0.1rem 0.15rem; + font-weight: normal; + color: var(--text-muted); +} + +.diary-cal-grid td a { + color: var(--link); + display: block; +} + +.diary-cal-grid td a:hover { + color: var(--link-hover); +} + +.diary-cal-grid td.cal-empty a { + color: var(--text-muted); +} + +.diary-cal-grid td.cal-empty a:hover { + color: var(--link-hover); +} + +.diary-cal-grid td.cal-today { + background: var(--bg-panel); +} + +.diary-cal-grid td.cal-current, +.diary-cal-grid td.cal-current a { + color: var(--primary-hover); +} + +.diary-cal-drop .dropdown-menu { + max-height: 14rem; + overflow-y: auto; +} + +.diary-cal-drop .dropdown-item.cal-current { + color: var(--primary-hover); +} + +.diary-cal-toggle { + display: none; +} + +/* === Responsive === */ +@media (max-width: 1100px) { + .diary-cal-toggle { + display: block; + background: none; + border: 1px solid var(--secondary); + color: var(--text); + font: inherit; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + padding: 0.4rem 0.75rem; + margin: 1rem auto; + width: calc(100% - 2rem); + max-width: 860px; + } + .diary-cal-toggle::before { + content: "▸ "; + color: var(--secondary); + } + .diary-cal-toggle[aria-expanded="true"]::before { + content: "▾ "; + } + .diary-cal { + position: static; + display: none; + width: calc(100% - 2rem); + max-width: 860px; + margin: 0 auto 1rem; + max-height: none; + } + .diary-cal.is-open { + display: block; + } +} + /* === Responsive === */ @media (max-width: 1100px) { .toc-toggle { @@ -567,6 +701,10 @@ hr { .toc.is-open { width: calc(100% - 1.5rem); } + .diary-cal-toggle, + .diary-cal.is-open { + width: calc(100% - 1.5rem); + } .modal-backdrop { padding: 0.5rem; align-items: flex-start; diff --git a/diary.go b/diary.go index 5c85c29..4c453c1 100644 --- a/diary.go +++ b/diary.go @@ -22,10 +22,14 @@ func init() { type diaryHandler struct{} func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { - depth, ok := findDiaryContext(root, fsPath) - if !ok || depth == 0 { + depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath) + if !ok { return nil } + widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth) + if depth == 0 { + return &specialPage{Widget: widget} + } var content template.HTML switch depth { case 1: @@ -35,30 +39,246 @@ func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { case 3: content = renderDiaryDay(fsPath, urlPath) } - return &specialPage{Content: content, SuppressListing: true} + return &specialPage{Content: content, SuppressListing: true, Widget: widget} } // findDiaryContext walks up from fsPath toward root looking for a // .page-settings file with type=diary. Returns the depth of fsPath -// relative to the diary root, and whether one was found. -// depth=0 means fsPath itself is the diary root. -func findDiaryContext(root, fsPath string) (int, bool) { - current := fsPath - for depth := 0; ; depth++ { - s := readPageSettings(current) +// relative to the diary root, the diary root fs path, its URL, and +// whether a diary root was found. depth=0 means fsPath itself is the root. +func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, diaryRootURL string, ok bool) { + currentFS := fsPath + currentURL := urlPath + for d := 0; ; d++ { + s := readPageSettings(currentFS) if s != nil && s.Type == "diary" { - return depth, true + return d, currentFS, currentURL, true } - if current == root { + if currentFS == root { break } - parent := filepath.Dir(current) - if parent == current { + parent := filepath.Dir(currentFS) + if parent == currentFS { break } - current = parent + currentFS = parent + currentURL = parentURL(currentURL) } - return 0, false + return 0, "", "", false +} + +type calDay struct { + Num int + URL string + HasEntry bool + IsToday bool + IsCurrent bool +} + +type calYear struct { + Num int + URL string + IsCurrent bool +} + +type calMonth struct { + Num int + Name string + URL string + IsCurrent bool +} + +type calendarData struct { + DisplayYear int + DisplayMonth int + MonthName string + DiaryURL string + YearURL string + MonthURL string + PrevMonURL string + NextMonURL string + Weeks [][]calDay + Years []calYear + AllMonths []calMonth +} + +var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html")) + +func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { + today := time.Now() + var displayYear, displayMonth, currentDay int + + switch depth { + case 0: + displayYear = today.Year() + displayMonth = int(today.Month()) + case 1: + y, err := strconv.Atoi(filepath.Base(fsPath)) + if err != nil { + return "" + } + displayYear = y + if y == today.Year() { + displayMonth = int(today.Month()) + } else { + displayMonth = 1 + } + case 2: + m, err := strconv.Atoi(filepath.Base(fsPath)) + if err != nil || m < 1 || m > 12 { + return "" + } + y, err := strconv.Atoi(filepath.Base(filepath.Dir(fsPath))) + if err != nil { + return "" + } + displayYear = y + displayMonth = m + case 3: + d, err := strconv.Atoi(filepath.Base(fsPath)) + if err != nil || d < 1 || d > 31 { + return "" + } + monthFS := filepath.Dir(fsPath) + m, err := strconv.Atoi(filepath.Base(monthFS)) + if err != nil || m < 1 || m > 12 { + return "" + } + y, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS))) + if err != nil { + return "" + } + displayYear = y + displayMonth = m + currentDay = d + default: + return "" + } + + // Which days in the display month have diary subfolders? + monthFSPath := filepath.Join(diaryRootFS, + fmt.Sprintf("%d", displayYear), + fmt.Sprintf("%02d", displayMonth)) + dayEntries, _ := os.ReadDir(monthFSPath) + hasDayEntry := map[int]bool{} + for _, e := range dayEntries { + if !e.IsDir() { + continue + } + d, err := strconv.Atoi(e.Name()) + if err != nil || d < 1 || d > 31 { + continue + } + hasDayEntry[d] = true + } + + // Build calendar grid with Monday as first column. + firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC) + startOffset := int(firstDay.Weekday()+6) % 7 + daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day() + + monthURLBase := path.Join(diaryRootURL, + fmt.Sprintf("%d", displayYear), + fmt.Sprintf("%02d", displayMonth)) + "/" + + var weeks [][]calDay + week := make([]calDay, 7) + col := startOffset + for d := 1; d <= daysInMonth; d++ { + dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/" + cell := calDay{Num: d, HasEntry: hasDayEntry[d]} + if cell.HasEntry { + cell.URL = dayURL + } else { + cell.URL = dayURL + "?edit" + } + cell.IsCurrent = d == currentDay + cell.IsToday = d == today.Day() && + time.Month(displayMonth) == today.Month() && + displayYear == today.Year() + week[col] = cell + col++ + if col == 7 { + weeks = append(weeks, week) + week = make([]calDay, 7) + col = 0 + } + } + if col > 0 { + weeks = append(weeks, week) + } + + prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC) + next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC) + prevMonURL := path.Join(diaryRootURL, + fmt.Sprintf("%d", prev.Year()), + fmt.Sprintf("%02d", int(prev.Month()))) + "/" + nextMonURL := path.Join(diaryRootURL, + fmt.Sprintf("%d", next.Year()), + fmt.Sprintf("%02d", int(next.Month()))) + "/" + yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/" + + // Collect all year subdirectories in diary root (descending). + yearEntries, _ := os.ReadDir(diaryRootFS) + var years []calYear + yearSet := map[int]bool{} + for _, e := range yearEntries { + if !e.IsDir() { + continue + } + y, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + yearSet[y] = true + years = append(years, calYear{ + Num: y, + URL: path.Join(diaryRootURL, e.Name()) + "/", + IsCurrent: y == displayYear, + }) + } + if !yearSet[displayYear] { + years = append(years, calYear{ + Num: displayYear, + URL: yearURL, + IsCurrent: true, + }) + } + sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) + + // All 12 months, ascending, linked to current display year. + allMonths := make([]calMonth, 12) + for m := 1; m <= 12; m++ { + allMonths[m-1] = calMonth{ + Num: m, + Name: germanMonths[time.Month(m)], + URL: path.Join(diaryRootURL, + fmt.Sprintf("%d", displayYear), + fmt.Sprintf("%02d", m)) + "/", + IsCurrent: m == displayMonth, + } + } + + data := calendarData{ + DisplayYear: displayYear, + DisplayMonth: displayMonth, + MonthName: germanMonths[time.Month(displayMonth)], + DiaryURL: diaryRootURL, + YearURL: yearURL, + MonthURL: monthURLBase, + PrevMonURL: prevMonURL, + NextMonURL: nextMonURL, + Weeks: weeks, + Years: years, + AllMonths: allMonths, + } + + var buf bytes.Buffer + if err := diaryCalTmpl.Execute(&buf, data); err != nil { + log.Printf("diary calendar template: %v", err) + return "" + } + return template.HTML(buf.String()) } // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. diff --git a/main.go b/main.go index 6267d60..3cf36e7 100644 --- a/main.go +++ b/main.go @@ -22,9 +22,11 @@ var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page. // specialPage is the result returned by a pageTypeHandler. // Content is injected into the page after the standard markdown content. // SuppressListing hides the default file/folder listing. +// Widget is a persistent sidebar widget rendered outside the main content area. type specialPage struct { Content template.HTML SuppressListing bool + Widget template.HTML } // pageTypeHandler is implemented by each special folder type (diary, gallery, …). @@ -168,8 +170,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa } var specialContent template.HTML + var diaryWidget template.HTML if special != nil { specialContent = special.Content + diaryWidget = special.Widget } rawContent := string(rawMD) @@ -192,6 +196,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa Content: rendered, Entries: entries, SpecialContent: specialContent, + DiaryWidget: diaryWidget, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/render.go b/render.go index 2b80e46..ce61b7f 100644 --- a/render.go +++ b/render.go @@ -46,6 +46,7 @@ type pageData struct { Content template.HTML Entries []entry SpecialContent template.HTML + DiaryWidget template.HTML } // pageSettings holds the parsed contents of a .page-settings file.