diff --git a/README.md b/README.md index 715c8e5..44e5bc0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ * Weather * Bookmarks * Latest YouTube videos from specific channels +* Clock * Calendar * Stocks * iframe diff --git a/docs/configuration.md b/docs/configuration.md index 9143e0f..0b08704 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ - [Repository](#repository) - [Bookmarks](#bookmarks) - [Calendar](#calendar) + - [Clock](#clock) - [Stocks](#stocks) - [Twitch Channels](#twitch-channels) - [Twitch Top Games](#twitch-top-games) @@ -34,6 +35,7 @@ pages: columns: - size: small widgets: + - type: clock - type: calendar - type: rss @@ -963,6 +965,51 @@ Whether to open the link in the same tab or a new one. Whether to hide the colored arrow on each link. +### Clock +Display a clock showing the current time and date. Optionally, also display the the time in other timezones. + +Example: + +```yaml +- type: clock + hour-format: 24h + timezones: + - timezone: Europe/Paris + label: Paris + - timezone: America/New_York + label: New York + - timezone: Asia/Tokyo + label: Tokyo +``` + +Preview: + +![](images/clock-widget-preview.png) + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| hour-format | string | no | 24h | +| timezones | array | no | | + +##### `hour-format` +Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`. + +#### Properties for each timezone + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| timezone | string | yes | | +| label | string | no | | + +##### `timezone` +A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +##### `label` +Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else. + + ### Calendar Display a calendar. diff --git a/docs/images/clock-widget-preview.png b/docs/images/clock-widget-preview.png new file mode 100644 index 0000000..bf809c5 Binary files /dev/null and b/docs/images/clock-widget-preview.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 3c98ba3..982d337 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -849,6 +849,10 @@ body { transform: translate(-50%, -50%); } +.clock-time span { + color: var(--color-text-highlight); +} + .monitor-site-icon { display: block; opacity: 0.8; diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index fcc2043..ccb2ab3 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -103,7 +103,7 @@ function updateRelativeTimeForElements(elements) if (timestamp === undefined) continue - element.innerText = relativeTimeSince(timestamp); + element.textContent = relativeTimeSince(timestamp); } } @@ -341,6 +341,112 @@ function afterContentReady(callback) { contentReadyCallbacks.push(callback); } +const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +function makeSettableTimeElement(element, hourFormat) { + const fragment = document.createDocumentFragment(); + const hour = document.createElement('span'); + const minute = document.createElement('span'); + const amPm = document.createElement('span'); + fragment.append(hour, document.createTextNode(':'), minute); + + if (hourFormat == '12h') { + fragment.append(document.createTextNode(' '), amPm); + } + + element.append(fragment); + + return (date) => { + const hours = date.getHours(); + + if (hourFormat == '12h') { + amPm.textContent = hours < 12 ? 'AM' : 'PM'; + hour.textContent = hours % 12 || 12; + } else { + hour.textContent = hours < 10 ? '0' + hours : hours; + } + + const minutes = date.getMinutes(); + minute.textContent = minutes < 10 ? '0' + minutes : minutes; + }; +}; + +function timeInZone(now, zone) { + let timeInZone; + + try { + timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone })); + } catch (e) { + // TODO: indicate to the user that this is an invalid timezone + console.error(e); + timeInZone = now + } + + const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60); + + return { time: timeInZone, diffInHours: diffInHours }; +} + +function setupClocks() { + const clocks = document.getElementsByClassName('clock'); + + if (clocks.length == 0) { + return; + } + + const updateCallbacks = []; + + for (var i = 0; i < clocks.length; i++) { + const clock = clocks[i]; + const hourFormat = clock.dataset.hourFormat; + const localTimeContainer = clock.querySelector('[data-local-time]'); + const localDateElement = localTimeContainer.querySelector('[data-date]'); + const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]'); + const localYearElement = localTimeContainer.querySelector('[data-year]'); + const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]'); + + const setLocalTime = makeSettableTimeElement( + localTimeContainer.querySelector('[data-time]'), + hourFormat + ); + + updateCallbacks.push((now) => { + setLocalTime(now); + localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()]; + localWeekdayElement.textContent = weekDayNames[now.getDay()]; + localYearElement.textContent = now.getFullYear(); + }); + + for (var z = 0; z < timeZoneContainers.length; z++) { + const timeZoneContainer = timeZoneContainers[z]; + const diffElement = timeZoneContainer.querySelector('[data-time-diff]'); + + const setZoneTime = makeSettableTimeElement( + timeZoneContainer.querySelector('[data-time]'), + hourFormat + ); + + updateCallbacks.push((now) => { + const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone); + setZoneTime(time); + diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h'; + }); + } + } + + const updateClocks = () => { + const now = new Date(); + + for (var i = 0; i < updateCallbacks.length; i++) + updateCallbacks[i](now); + + setTimeout(updateClocks, (60 - now.getSeconds()) * 1000); + }; + + updateClocks(); +} + async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); @@ -349,6 +455,7 @@ async function setupPage() { pageContentElement.innerHTML = pageContent; try { + setupClocks() setupCarousels(); setupCollapsibleLists(); setupCollapsibleGrids(); diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6ae..8dff7c0 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -15,6 +15,7 @@ var ( PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") PageContentTemplate = compileTemplate("content.html") CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") + ClockTemplate = compileTemplate("clock.html", "widget-base.html") BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") WeatherTemplate = compileTemplate("weather.html", "widget-base.html") diff --git a/internal/assets/templates/clock.html b/internal/assets/templates/clock.html new file mode 100644 index 0000000..2be2d1c --- /dev/null +++ b/internal/assets/templates/clock.html @@ -0,0 +1,30 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+
+
+
+
+
+
+
+
+
+
+ {{ if gt (len .Timezones) 0 }} +
+ + {{ end }} +
+{{ end }} diff --git a/internal/widget/clock.go b/internal/widget/clock.go new file mode 100644 index 0000000..efe8ccd --- /dev/null +++ b/internal/widget/clock.go @@ -0,0 +1,50 @@ +package widget + +import ( + "errors" + "fmt" + "html/template" + "time" + + "github.com/glanceapp/glance/internal/assets" +) + +type Clock struct { + widgetBase `yaml:",inline"` + cachedHTML template.HTML `yaml:"-"` + HourFormat string `yaml:"hour-format"` + Timezones []struct { + Timezone string `yaml:"timezone"` + Label string `yaml:"label"` + } `yaml:"timezones"` +} + +func (widget *Clock) Initialize() error { + widget.withTitle("Clock").withError(nil) + + if widget.HourFormat == "" { + widget.HourFormat = "24h" + } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" { + return errors.New("invalid hour format for clock widget, must be either 12h or 24h") + } + + for t := range widget.Timezones { + if widget.Timezones[t].Timezone == "" { + return errors.New("missing timezone value for clock widget") + } + + _, err := time.LoadLocation(widget.Timezones[t].Timezone) + + if err != nil { + return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err) + } + } + + widget.cachedHTML = widget.render(widget, assets.ClockTemplate) + + return nil +} + +func (widget *Clock) Render() template.HTML { + return widget.cachedHTML +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 3707b7e..934a92e 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -19,6 +19,8 @@ func New(widgetType string) (Widget, error) { switch widgetType { case "calendar": return &Calendar{}, nil + case "clock": + return &Clock{}, nil case "weather": return &Weather{}, nil case "bookmarks":