소스 검색

Merge pull request #82 from yardenshoham/add-clock-widget

Add a clock widget
Svilen Markov 1 년 전
부모
커밋
0f42567d98

+ 1 - 0
README.md

@@ -11,6 +11,7 @@
 * Weather
 * Bookmarks
 * Latest YouTube videos from specific channels
+* Clock
 * Calendar
 * Stocks
 * iframe

+ 47 - 0
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.
 

BIN
docs/images/clock-widget-preview.png


+ 4 - 0
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;

+ 108 - 1
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();

+ 1 - 0
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")

+ 30 - 0
internal/assets/templates/clock.html

@@ -0,0 +1,30 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="clock" data-hour-format="{{ .HourFormat }}">
+    <div class="flex justify-between items-center" data-local-time>
+        <div>
+            <div class="color-highlight size-h1" data-date></div>
+            <div data-year></div>
+        </div>
+        <div class="text-right">
+            <div class="clock-time size-h1" data-time></div>
+            <div data-weekday></div>
+        </div>
+    </div>
+    {{ if gt (len .Timezones) 0 }}
+    <hr class="margin-block-10">
+    <ul class="list list-gap-10">
+        {{ range .Timezones }}
+        <li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
+            <div class="grow min-width-0">
+                <div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
+            </div>
+            <div class="color-subdue" data-time-diff></div>
+            <div class="size-h4 clock-time shrink-0 text-right" data-time></div>
+        </li>
+        {{ end }}
+    </ul>
+    {{ end }}
+</div>
+{{ end }}

+ 50 - 0
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
+}

+ 2 - 0
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":