From 306fb3cb336ed713f903d77661686e3e4a5e948c Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:47:20 +0000 Subject: [PATCH] Add new calendar and deprecate old --- internal/glance/static/js/animations.js | 33 +++ internal/glance/static/js/calendar.js | 212 ++++++++++++++++++++ internal/glance/static/js/main.js | 12 ++ internal/glance/static/js/templating.js | 190 ++++++++++++++++++ internal/glance/static/main.css | 73 ++++++- internal/glance/templates/calendar.html | 29 +-- internal/glance/templates/old-calendar.html | 34 ++++ internal/glance/widget-calendar.go | 93 +++------ internal/glance/widget-old-calendar.go | 86 ++++++++ internal/glance/widget.go | 2 + 10 files changed, 665 insertions(+), 99 deletions(-) create mode 100644 internal/glance/static/js/animations.js create mode 100644 internal/glance/static/js/calendar.js create mode 100644 internal/glance/static/js/templating.js create mode 100644 internal/glance/templates/old-calendar.html create mode 100644 internal/glance/widget-old-calendar.go diff --git a/internal/glance/static/js/animations.js b/internal/glance/static/js/animations.js new file mode 100644 index 0000000..db5b0aa --- /dev/null +++ b/internal/glance/static/js/animations.js @@ -0,0 +1,33 @@ +export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +export function directions(anim, opt, ...dirs) { + return dirs.map(dir => anim({ direction: dir, ...opt })); +} + +export function slideFade({ + direction = 'left', + fill = 'backwards', + duration = 200, + distance = '1rem', + easing = 'ease', + offset = 0, +}) { + const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y'; + const negative = direction === 'left' || direction === 'up' ? '-' : ''; + const amount = negative + distance; + + return { + keyframes: [ + { + offset: offset, + opacity: 0, + transform: `translate${axis}(${amount})`, + } + ], + options: { + duration: duration, + easing: easing, + fill: fill, + }, + }; +} diff --git a/internal/glance/static/js/calendar.js b/internal/glance/static/js/calendar.js new file mode 100644 index 0000000..fb6602e --- /dev/null +++ b/internal/glance/static/js/calendar.js @@ -0,0 +1,212 @@ +import { directions, easeOutQuint, slideFade } from "./animations.js"; +import { elem, repeat, text } from "./templating.js"; + +const FULL_MONTH_SLOTS = 7*6; +const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const leftArrowSvg = ``; + +const rightArrowSvg = ``; + +const undoArrowSvg = ``; + +const [datesExitLeft, datesExitRight] = directions( + slideFade, { distance: "2rem", duration: 120, offset: 1 }, + "left", "right" +); + +const [datesEntranceLeft, datesEntranceRight] = directions( + slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint }, + "left", "right" +); + +const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 }); + +export default function(element) { + element.swap(Calendar( + Number(element.dataset.firstDayOfWeek ?? 1) + )); +} + +// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days +function Calendar(firstDay) { + let header, dates; + let advanceTimeTicker; + let now = new Date(); + let activeDate; + + const update = (newDate) => { + header.component.update(now, newDate); + dates.component.update(now, newDate); + activeDate = newDate; + }; + + const autoAdvanceNow = () => { + advanceTimeTicker = setTimeout(() => { + // TODO: don't auto advance if looking at a different month + update(now = new Date()); + autoAdvanceNow(); + }, msTillNextDay()); + }; + + const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1); + const nextClicked = () => update(adjacentMonth(1)); + const prevClicked = () => update(adjacentMonth(-1)); + const undoClicked = () => update(now); + + const calendar = elem().classes("calendar").append( + header = Header(nextClicked, prevClicked, undoClicked), + dates = Dates(firstDay) + ); + + update(now); + autoAdvanceNow(); + + return calendar.component({ + suspend: () => clearTimeout(advanceTimeTicker) + }); +} + +function Header(nextClicked, prevClicked, undoClicked) { + let month, monthNumber, year, undo; + const button = () => elem("button").classes("calendar-header-button"); + + const monthAndYear = elem().classes("size-h2", "color-highlight").append( + month = text(), + " ", + year = elem("span").classes("size-h3"), + undo = button() + .hide() + .classes("calendar-undo-button") + .attr("title", "Back to current month") + .on("click", undoClicked) + .html(undoArrowSvg) + ); + + const monthSwitcher = elem() + .classes("flex", "gap-7", "items-center") + .append( + button() + .attr("title", "Previous month") + .on("click", prevClicked) + .html(leftArrowSvg), + monthNumber = elem() + .classes("color-highlight") + .styles({ marginTop: "0.1rem" }), + button() + .attr("title", "Next month") + .on("click", nextClicked) + .html(rightArrowSvg), + ); + + return elem().classes("flex", "justify-between", "items-center").append( + monthAndYear, + monthSwitcher + ).component({ + update: function (now, newDate) { + month.text(MONTH_NAMES[newDate.getMonth()]); + year.text(newDate.getFullYear()); + const m = newDate.getMonth() + 1; + monthNumber.text((m < 10 ? "0" : "") + m); + + if (!datesWithinSameMonth(now, newDate)) { + if (undo.isHidden()) undo.show().animate(undoEntrance); + } else { + undo.hide(); + } + + return this; + } + }); +} + +function Dates(firstDay) { + let dates, lastRenderedDate; + + const updateFullMonth = function(now, newDate) { + const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay(); + const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7; + const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth()); + const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays); + const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1) + const isCurrentMonth = datesWithinSameMonth(now, newDate); + const currentDate = now.getDate(); + + let children = dates.children; + let index = 0; + + for (let i = 0; i < FULL_MONTH_SLOTS; i++) { + children[i].clearClasses("calendar-spillover-date", "calendar-current-date"); + } + + for (let i = 0; i < previousMonthSpilloverDays; i++, index++) { + children[index].classes("calendar-spillover-date").text( + previousMonthDays - previousMonthSpilloverDays + i + 1 + ) + } + + for (let i = 1; i <= currentMonthDays; i++, index++) { + children[index] + .classesIf(isCurrentMonth && i === currentDate, "calendar-current-date") + .text(i); + } + + for (let i = 0; i < nextMonthSpilloverDays; i++, index++) { + children[index].classes("calendar-spillover-date").text(i + 1); + } + + lastRenderedDate = newDate; + }; + + const update = function(now, newDate) { + if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) { + updateFullMonth(now, newDate); + return; + } + + const next = newDate > lastRenderedDate; + dates.animateUpdate( + () => updateFullMonth(now, newDate), + next ? datesExitLeft : datesExitRight, + next ? datesEntranceRight : datesEntranceLeft, + ); + } + + return elem().append( + elem().classes("calendar-dates", "margin-top-15").append( + ...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text( + WEEKDAY_ABBRS[(firstDay + i) % 7] + )) + ), + + dates = elem().classes("calendar-dates", "margin-top-3").append( + ...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS) + ) + ).component({ update }); +} + +function datesWithinSameMonth(d1, d2) { + return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth(); +} + +function daysInMonth(year, month) { + return new Date(year, month + 1, 0).getDate(); +} + +function msTillNextDay(now) { + now = now || new Date(); + + return 86_400_000 - ( + now.getMilliseconds() + + now.getSeconds() * 1000 + + now.getMinutes() * 60_000 + + now.getHours() * 3_600_000 + ); +} diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/main.js index ac8c0b0..a10804e 100644 --- a/internal/glance/static/js/main.js +++ b/internal/glance/static/js/main.js @@ -625,6 +625,17 @@ function setupClocks() { updateClocks(); } +async function setupCalendars() { + const elems = document.getElementsByClassName("calendar"); + if (elems.length == 0) return; + + // TODO: implement prefetching, currently loads as a nasty waterfall of requests + const calendar = await import ('./calendar.js'); + + for (let i = 0; i < elems.length; i++) + calendar.default(elems[i]); +} + function setupTruncatedElementTitles() { const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines"); @@ -648,6 +659,7 @@ async function setupPage() { try { setupPopovers(); setupClocks() + await setupCalendars(); setupCarousels(); setupSearchBoxes(); setupCollapsibleLists(); diff --git a/internal/glance/static/js/templating.js b/internal/glance/static/js/templating.js new file mode 100644 index 0000000..05824bc --- /dev/null +++ b/internal/glance/static/js/templating.js @@ -0,0 +1,190 @@ +export function elem(tag = "div") { + return document.createElement(tag); +} + +export function fragment(...children) { + const f = document.createDocumentFragment(); + if (children) f.append(...children); + return f; +} + +export function text(str = "") { + return document.createTextNode(str); +} + +export function repeat(n, fn) { + const elems = Array(n); + + for (let i = 0; i < n; i++) + elems[i] = fn(i); + + return elems; +} + +export function find(selector) { + return document.querySelector(selector); +} + +export function findAll(selector) { + return document.querySelectorAll(selector); +} + +const ep = HTMLElement.prototype; +const fp = DocumentFragment.prototype; +const tp = Text.prototype; + +ep.classes = function(...classes) { + this.classList.add(...classes); + return this; +} + +ep.find = function(selector) { + return this.querySelector(selector); +} + +ep.findAll = function(selector) { + return this.querySelectorAll(selector); +} + +ep.classesIf = function(cond, ...classes) { + cond ? this.classList.add(...classes) : this.classList.remove(...classes); + return this; +} + +ep.hide = function() { + this.style.display = "none"; + return this; +} + +ep.show = function() { + this.style.removeProperty("display"); + return this; +} + +ep.showIf = function(cond) { + cond ? this.show() : this.hide(); + return this; +} + +ep.isHidden = function() { + return this.style.display === "none"; +} + +ep.clearClasses = function(...classes) { + classes.length ? this.classList.remove(...classes) : this.className = ""; + return this; +} + +ep.hasClass = function(className) { + return this.classList.contains(className); +} + +ep.attr = function(name, value) { + this.setAttribute(name, value); + return this; +} + +ep.attrs = function(attrs) { + for (const [name, value] of Object.entries(attrs)) + this.setAttribute(name, value); + return this; +} + +ep.tap = function(fn) { + fn(this); + return this; +} + +ep.text = function(text) { + this.innerText = text; + return this; +} + +ep.html = function(html) { + this.innerHTML = html; + return this; +} + +ep.appendTo = function(parent) { + parent.appendChild(this); + return this; +} + +ep.swap = function(element) { + this.replaceWith(element); + return element; +} + +ep.on = function(event, callback, options) { + if (typeof event === "string") { + this.addEventListener(event, callback, options); + return this; + } + + for (let i = 0; i < event.length; i++) + this.addEventListener(event[i], callback, options); + + return this; +} + +const epAppend = ep.append; +ep.append = function(...children) { + epAppend.apply(this, children); + return this; +} + +ep.duplicate = function(n) { + const elems = Array(n); + + for (let i = 0; i < n; i++) + elems[i] = this.cloneNode(true); + + return elems; +} + +ep.styles = function(s) { + Object.assign(this.style, s); + return this; +} + +const epAnimate = ep.animate; +ep.animate = function(anim, callback) { + const a = epAnimate.call(this, anim.keyframes, anim.options); + if (callback) a.onfinish = () => callback(this, a); + return this; +} + +ep.animateUpdate = function(update, exit, entrance) { + this.animate(exit, () => { + update(this); + this.animate(entrance); + }); + + return this; +} + +ep.styleVar = function(name, value) { + this.style.setProperty(`--${name}`, value); + return this; +} + +ep.component = function (methods) { + this.component = methods; + return this; +} + +const fpAppend = fp.append; +fp.append = function(...children) { + fpAppend.apply(this, children); + return this; +} + +fp.appendTo = function(parent) { + parent.appendChild(this); + return this; +} + +tp.text = function(text) { + this.nodeValue = text; + return this; +} diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 06f7b39..0057671 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -17,7 +17,7 @@ --cm: 1; --tsm: 1; - --widget-gap: 25px; + --widget-gap: 23px; --widget-content-vertical-padding: 15px; --widget-content-horizontal-padding: 17px; --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding); @@ -294,6 +294,12 @@ pre { width: 10px; } +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 0.1rem; + border-radius: var(--border-radius); +} + *, *::before, *::after { box-sizing: border-box; } @@ -1074,19 +1080,78 @@ details[open] .summary::after { filter: invert(1); } -.calendar-day { +.old-calendar-day { width: calc(100% / 7); text-align: center; padding: 0.6rem 0; } - -.calendar-day-today { +.old-calendar-day-today { border-radius: var(--border-radius); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%))); color: var(--color-text-highlight); } +.calendar-dates { + text-align: center; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-date { + padding: 0.4rem 0; + color: var(--color-text-paragraph); + position: relative; + border-radius: var(--border-radius); + background: none; + border: none; + font: inherit; +} + +.calendar-current-date { + border-radius: var(--border-radius); + background-color: var(--color-popover-border); + color: var(--color-text-highlight); +} + +.calendar-spillover-date { + color: var(--color-text-subdue); +} + +.calendar-header-button { + position: relative; + cursor: pointer; + width: 2rem; + height: 2rem; + z-index: 1; + background: none; + border: none; +} + +.calendar-header-button::before { + content: ''; + position: absolute; + inset: -0.2rem; + border-radius: var(--border-radius); + background-color: var(--color-text-subdue); + opacity: 0; + transition: opacity 0.2s; + z-index: -1; +} + +.calendar-header-button:hover::before { + opacity: 0.4; +} + +.calendar-undo-button { + display: inline-block; + vertical-align: text-top; + width: 2rem; + height: 2rem; + margin-left: 0.7rem; +} + .dns-stats-totals { transition: opacity .3s; transition-delay: 50ms; diff --git a/internal/glance/templates/calendar.html b/internal/glance/templates/calendar.html index 020d6ac..b3c4a69 100644 --- a/internal/glance/templates/calendar.html +++ b/internal/glance/templates/calendar.html @@ -2,33 +2,6 @@ {{ define "widget-content" }}
{{ end }} diff --git a/internal/glance/templates/old-calendar.html b/internal/glance/templates/old-calendar.html new file mode 100644 index 0000000..b43d43d --- /dev/null +++ b/internal/glance/templates/old-calendar.html @@ -0,0 +1,34 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + +{{ end }} diff --git a/internal/glance/widget-calendar.go b/internal/glance/widget-calendar.go index 518bc22..9537e53 100644 --- a/internal/glance/widget-calendar.go +++ b/internal/glance/widget-calendar.go @@ -1,86 +1,45 @@ package glance import ( - "context" + "errors" "html/template" "time" ) var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html") +var calendarWeekdaysToInt = map[string]time.Weekday{ + "sunday": time.Sunday, + "monday": time.Monday, + "tuesday": time.Tuesday, + "wednesday": time.Wednesday, + "thursday": time.Thursday, + "friday": time.Friday, + "saturday": time.Saturday, +} + type calendarWidget struct { - widgetBase `yaml:",inline"` - Calendar *calendar - StartSunday bool `yaml:"start-sunday"` + widgetBase `yaml:",inline"` + FirstDayOfWeek string `yaml:"first-day-of-week"` + FirstDay int `yaml:"-"` + cachedHTML template.HTML `yaml:"-"` } func (widget *calendarWidget) initialize() error { - widget.withTitle("Calendar").withCacheOnTheHour() + widget.withTitle("Calendar").withError(nil) + + if widget.FirstDayOfWeek == "" { + widget.FirstDayOfWeek = "monday" + } else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok { + return errors.New("invalid first day of week") + } + + widget.FirstDay = int(calendarWeekdaysToInt[widget.FirstDayOfWeek]) + widget.cachedHTML = widget.renderTemplate(widget, calendarWidgetTemplate) return nil } -func (widget *calendarWidget) update(ctx context.Context) { - widget.Calendar = newCalendar(time.Now(), widget.StartSunday) - widget.withError(nil).scheduleNextUpdate() -} - func (widget *calendarWidget) Render() template.HTML { - return widget.renderTemplate(widget, calendarWidgetTemplate) -} - -type calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -// TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing between showing the previous and next week and the entire month -func newCalendar(now time.Time, startSunday bool) *calendar { - year, week := now.ISOWeek() - weekday := now.Weekday() - if !startSunday { - weekday = (weekday + 6) % 7 // Shift Monday to 0 - } - - currentMonthDays := daysInMonth(now.Month(), year) - - var previousMonthDays int - - if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 { - previousMonthDays = daysInMonth(12, year-1) - } else { - previousMonthDays = daysInMonth(previousMonthNumber, year) - } - - startDaysFrom := now.Day() - int(weekday) - 7 - - days := make([]int, 21) - - for i := 0; i < 21; i++ { - day := startDaysFrom + i - - if day < 1 { - day = previousMonthDays + day - } else if day > currentMonthDays { - day = day - currentMonthDays - } - - days[i] = day - } - - return &calendar{ - CurrentDay: now.Day(), - CurrentWeekNumber: week, - CurrentMonthName: now.Month().String(), - CurrentYear: year, - Days: days, - } -} - -func daysInMonth(m time.Month, year int) int { - return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() + return widget.cachedHTML } diff --git a/internal/glance/widget-old-calendar.go b/internal/glance/widget-old-calendar.go new file mode 100644 index 0000000..e4fbe74 --- /dev/null +++ b/internal/glance/widget-old-calendar.go @@ -0,0 +1,86 @@ +package glance + +import ( + "context" + "html/template" + "time" +) + +var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html") + +type oldCalendarWidget struct { + widgetBase `yaml:",inline"` + Calendar *calendar + StartSunday bool `yaml:"start-sunday"` +} + +func (widget *oldCalendarWidget) initialize() error { + widget.withTitle("Calendar").withCacheOnTheHour() + + return nil +} + +func (widget *oldCalendarWidget) update(ctx context.Context) { + widget.Calendar = newCalendar(time.Now(), widget.StartSunday) + widget.withError(nil).scheduleNextUpdate() +} + +func (widget *oldCalendarWidget) Render() template.HTML { + return widget.renderTemplate(widget, oldCalendarWidgetTemplate) +} + +type calendar struct { + CurrentDay int + CurrentWeekNumber int + CurrentMonthName string + CurrentYear int + Days []int +} + +// TODO: very inflexible, refactor to allow more customizability +// TODO: allow changing between showing the previous and next week and the entire month +func newCalendar(now time.Time, startSunday bool) *calendar { + year, week := now.ISOWeek() + weekday := now.Weekday() + if !startSunday { + weekday = (weekday + 6) % 7 // Shift Monday to 0 + } + + currentMonthDays := daysInMonth(now.Month(), year) + + var previousMonthDays int + + if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 { + previousMonthDays = daysInMonth(12, year-1) + } else { + previousMonthDays = daysInMonth(previousMonthNumber, year) + } + + startDaysFrom := now.Day() - int(weekday) - 7 + + days := make([]int, 21) + + for i := 0; i < 21; i++ { + day := startDaysFrom + i + + if day < 1 { + day = previousMonthDays + day + } else if day > currentMonthDays { + day = day - currentMonthDays + } + + days[i] = day + } + + return &calendar{ + CurrentDay: now.Day(), + CurrentWeekNumber: week, + CurrentMonthName: now.Month().String(), + CurrentYear: year, + Days: days, + } +} + +func daysInMonth(m time.Month, year int) int { + return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 7e8a618..4130925 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -23,6 +23,8 @@ func newWidget(widgetType string) (widget, error) { switch widgetType { case "calendar": w = &calendarWidget{} + case "calendar-legacy": + w = &oldCalendarWidget{} case "clock": w = &clockWidget{} case "weather":