1
0
Fork 0

Prototype interface design

pull/1/head
Ambrose Chua 2020-09-28 09:11:07 +08:00
parent 8def2eeaeb
commit 3839df846c
12 changed files with 712 additions and 1 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
datetime.link

2
CNAME
View File

@ -1 +1 @@
datetime.link
datetime.link

140
css/styles.css Normal file
View File

@ -0,0 +1,140 @@
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body {
margin: 0;
font-family: "Lora", "Didot", "Garamond", serif;
display: flex;
flex-direction: column;
min-height: 100%;
}
.list-inline {
list-style: none;
padding: 0;
}
.list-inline li {
display: inline-block;
}
.list-inline li:before {
content: "\2022";
padding: 0 0.25rem;
}
.list-inline li:first-child:before {
content: none;
}
main {
flex-grow: 1;
margin: 0 auto;
padding: 2rem 0.5rem;
width: 100%;
max-width: 50rem;
}
footer {
margin: 0 auto;
padding: 0.5rem 1.5rem;
width: 100%;
max-width: 50rem;
font-size: 0.75em;
opacity: 0.5;
text-align: right;
}
footer a {
color: inherit;
}
datetime-zone {
margin: 1rem;
padding: 1rem;
background: rgba(128, 128, 128, 0.25);
border-radius: 0.5rem;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
}
datetime-zoneinfo {
flex-grow: 1;
padding: 0.5rem;
width: 18rem;
display: flex;
flex-direction: column;
justify-content: center;
}
datetime-zonename {
display: block;
font-weight: bold;
}
datetime-zoneoffset {
display: block;
}
datetime-datetime {
flex-grow: 1;
width: 22rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
datetime-date,
datetime-time {
padding: 0.5rem;
font-size: 1em;
text-align: center;
}
datetime-date {
width: 11em;
}
datetime-time {
font-size: 1.5em;
width: 7em;
}
/*
datetime-date {
width: 10rem;
}
datetime-time {
width: 5rem;
}
*/
@media (max-width: 28rem) {
datetime-date,
datetime-time {
width: 100%;
}
}
@media (prefers-color-scheme: dark) {
html {
background: #000;
color: #fff;
}
}

3
generate.go Normal file
View File

@ -0,0 +1,3 @@
package main
//go:generate go run scripts/bcp47timezone.go

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/serverwentdown/datetime.link
go 1.14

1
js/bcp47timezone.json Normal file

File diff suppressed because one or more lines are too long

282
js/interactive.js Normal file
View File

@ -0,0 +1,282 @@
'use strict';
// Date formatting
//
// Import Luxon DateTime formatting wrapper.
async function importDateTime() {
return luxon.DateTime;
}
// Zone data
//
// This maps an IANA zone into readable strings with bcp47. Sadly, Intl doesn't provide such strings.
async function importZoneData() {
const zoneData = {};
const res = await fetch("/js/bcp47timezone.json");
const data = await res.json();
for (const timezone of data) {
for (const alias of timezone.aliases) {
zoneData[alias] = timezone;
}
}
return zoneData;
}
// Start
Promise.all([
importDateTime(),
importZoneData(),
]).then(dependencies => {
start(...dependencies);
});
function start(DateTime, zoneData) {
// Datetime translation
//
// This maps a datetime and zone into a zone name, date and time in the current locale.
function translateDatetime(datetime, zone) {
const dt = DateTime.fromISO(datetime).setZone(zone);
const dateString = dt.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY);
const timeString = dt.toLocaleString(DateTime.TIME_SIMPLE);
const zoneObject = zoneData[zone];
return {
zone: zoneObject,
date: dateString,
time: timeString,
};
}
// URL parser
//
// This maps the URL path into various parts. The server-side parser should match this code exactly.
function parsePath(path) {
let cleanup = path;
cleanup = cleanup.replace(/^\/+/, ""); // Remove start slashes
cleanup = cleanup.replace(/\/+$/, ""); // Remove end slashes
let parts = cleanup.split("/");
// Simple format: iso_time
if (parts.length == 1) {
return {
datetime: parts[0],
zones: ['local'],
};
}
// Simple format: iso_time/csv_zones
if (parts.length == 2) {
return {
datetime: parts[0],
zones: parsePathZones(parts[1]),
};
}
return null;
}
function parsePathZones(zones) {
let parts = zones.split(",");
let zs = parts.map(zone => {
return zone.replace("+", "/");
});
return zs;
}
// Zone
const zoneTemplate = document.createElement('template');
zoneTemplate.innerHTML = `
<slot></slot>
`;
class ZoneElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(zoneTemplate.content.cloneNode(true));
}
}
// ZoneInfo
const zoneInfoTemplate = document.createElement('template');
zoneInfoTemplate.innerHTML = `
<slot></slot>
`;
class ZoneInfoElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(zoneInfoTemplate.content.cloneNode(true));
}
}
// ZoneName
const zoneNameTemplate = document.createElement('template');
zoneNameTemplate.innerHTML = `
<slot></slot>
`;
class ZoneNameElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(zoneNameTemplate.content.cloneNode(true));
}
}
// ZoneOffset
const zoneOffsetTemplate = document.createElement('template');
zoneOffsetTemplate.innerHTML = `
<slot></slot>
`;
class ZoneOffsetElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(zoneOffsetTemplate.content.cloneNode(true));
}
}
// Datetime
const datetimeTemplate = document.createElement('template');
datetimeTemplate.innerHTML = `
<slot></slot>
`;
class DatetimeElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(datetimeTemplate.content.cloneNode(true));
}
}
// Date
const dateTemplate = document.createElement('template');
dateTemplate.innerHTML = `
<style>
#editor-input {
padding: 0;
border: 0;
width: 100%;
background: none;
color: inherit;
font-size: inherit;
font-weight: inherit;
font-family: inherit;
text-align: right;
}
@media (prefers-color-scheme: dark) {
::-webkit-calendar-picker-indicator {
filter: invert(1);
}
}
</style>
<input id="editor-input" type="date" required>
`;
class DateElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(dateTemplate.content.cloneNode(true));
}
connectedCallback() {
const editorInput = this.shadowRoot.querySelector('#editor-input');
const value = this.getAttribute('date');
editorInput.value = value;
editorInput.addEventListener('blur', () => {
// TODO
});
}
}
// Time
const timeTemplate = document.createElement('template');
timeTemplate.innerHTML = `
<style>
#editor-input {
padding: 0;
border: 0;
width: 100%;
background: none;
color: inherit;
font-size: inherit;
font-weight: inherit;
font-family: inherit;
text-align: right;
}
@media (prefers-color-scheme: dark) {
::-webkit-calendar-picker-indicator {
filter: invert(1);
}
}
</style>
<input id="editor-input" type="time" required pattern="[0-9]{2}:[0-9]{2}">
`;
class TimeElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(timeTemplate.content.cloneNode(true));
}
connectedCallback() {
const editorInput = this.shadowRoot.querySelector('#editor-input');
const value = this.getAttribute('time');
editorInput.value = value;
editorInput.addEventListener('blur', () => {
// TODO
});
}
}
customElements.define('datetime-zone', ZoneElement);
customElements.define('datetime-zoneinfo', ZoneInfoElement);
customElements.define('datetime-zonename', ZoneNameElement);
customElements.define('datetime-zoneoffset', ZoneOffsetElement);
customElements.define('datetime-datetime', DatetimeElement);
customElements.define('datetime-date', DateElement);
customElements.define('datetime-time', TimeElement);
// Page events
const path = window.location.pathname;
}

1
js/third-party/luxon.min.js vendored Normal file

File diff suppressed because one or more lines are too long

70
main.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"flag"
"html/template"
"log"
"net/http"
"time"
)
var listen string
var tmpl *template.Template
func main() {
var err error
flag.StringVar(&listen, "listen", ":8000", "Listen address")
flag.Parse()
server := &http.Server{
Addr: listen,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
tmpl, err = template.ParseGlob("templates/*")
if err != nil {
panic(err)
}
http.Handle("/js/", http.FileServer(http.Dir(".")))
http.Handle("/css/", http.FileServer(http.Dir(".")))
http.Handle("/favicon.ico", http.FileServer(http.Dir(".")))
http.HandleFunc("/", index)
err = server.ListenAndServe()
if err != nil {
panic(err)
}
}
func index(w http.ResponseWriter, req *http.Request) {
var err error
if req.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
accept := req.Header.Get("Accept")
responseType := chooseResponseType(accept)
switch responseType {
case responsePlain:
w.WriteHeader(http.StatusNotImplemented)
case responseHTML:
indexTmpl := tmpl.Lookup("index.html")
if indexTmpl == nil {
log.Printf("Unable to find index template")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = indexTmpl.Execute(w, nil)
if err != nil {
log.Printf("Error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}

83
mime.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"strconv"
"strings"
)
// Snippet from https://github.com/emicklei/go-restful/blob/master/mime.go
// MIT License
type mime struct {
media string
quality float64
}
// insertMime adds a mime to a list and keeps it sorted by quality.
func insertMime(l []mime, e mime) []mime {
for i, each := range l {
// if current mime has lower quality then insert before
if e.quality > each.quality {
left := append([]mime{}, l[0:i]...)
return append(append(left, e), l[i:]...)
}
}
return append(l, e)
}
const qFactorWeightingKey = "q"
// sortedMimes returns a list of mime sorted (desc) by its specified quality.
// e.g. text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
func sortedMimes(accept string) (sorted []mime) {
for _, each := range strings.Split(accept, ",") {
typeAndQuality := strings.Split(strings.Trim(each, " "), ";")
if len(typeAndQuality) == 1 {
sorted = insertMime(sorted, mime{typeAndQuality[0], 1.0})
} else {
// take factor
qAndWeight := strings.Split(typeAndQuality[1], "=")
if len(qAndWeight) == 2 && strings.Trim(qAndWeight[0], " ") == qFactorWeightingKey {
f, err := strconv.ParseFloat(qAndWeight[1], 64)
if err != nil {
// do nothing
} else {
sorted = insertMime(sorted, mime{typeAndQuality[0], f})
}
} else {
sorted = insertMime(sorted, mime{typeAndQuality[0], 1.0})
}
}
}
return
}
type responseType int
const (
responsePlain responseType = iota
responseHTML responseType = iota
)
const (
responsePlainMime = "text/plain"
responseHTMLMime = "text/html"
responseAnyMime = "*/*"
)
// chooseResponse returns a response type from an accept header
func chooseResponseType(accept string) responseType {
acceptSorted := sortedMimes(accept)
for _, m := range acceptSorted {
if m.media == responsePlainMime {
return responsePlain
}
if m.media == responseHTMLMime {
return responseHTML
}
if m.media == responseAnyMime {
return responseHTML
}
}
return responseHTML
}

70
scripts/bcp47timezone.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"strings"
"encoding/xml"
"io/ioutil"
"log"
"net/http"
)
const timezoneXML = "https://raw.githubusercontent.com/unicode-org/cldr/master/common/bcp47/timezone.xml"
func main() {
resp, err := http.Get(timezoneXML)
if err != nil {
log.Fatalf("Failed to fetch: %v", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to fetch: %v", err)
}
// Parse XML file
data := &ldmlData{}
err = xml.Unmarshal(body, &data)
if err != nil {
log.Fatalf("Failed to parse: %v", err)
}
// Remap into different format
timezonesData := make([]timezoneData, len(data.Keys))
for i, t := range data.Keys {
timezonesData[i] = timezoneData{
Name: t.Name,
Description: t.Description,
Aliases: strings.Split(t.Alias, " "),
}
}
// Encode JSON file
b, err := json.Marshal(timezonesData)
if err != nil {
log.Fatalf("Failed to encode: %v", err)
}
// Write JSON file
err = ioutil.WriteFile("js/bcp47timezone.json", b, 0644)
if err != nil {
log.Fatalf("Failed to write: %v", err)
}
}
type ldmlData struct {
Keys []ldmlType `xml:"keyword>key>type"`
}
type ldmlType struct {
Name string `xml:"name,attr"`
Description string `xml:"description,attr"`
Alias string `xml:"alias,attr"` // NOTE: space-separated values
}
type timezoneData struct {
Name string `json:"name"`
Description string `json:"description"`
Aliases []string `json:"aliases"`
}

57
templates/index.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>datetime.link</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<main id="app">
<datetime-zone>
<datetime-zoneinfo>
<datetime-zonename>India Standard Time (IST)</datetime-zonename>
<datetime-zoneoffset>(UTC +5:30)</datetime-zoneoffset>
</datetime-zoneinfo>
<datetime-datetime>
<datetime-date date="2020-06-02">2020-06-02</datetime-date>
<datetime-time time="14:00">14:00</datetime-time>
</datetime-datetime>
</datetime-zone>
<datetime-zone>
<datetime-zoneinfo>
<datetime-zonename>Singapore Time (SGT)</datetime-zonename>
<datetime-zoneoffset>(UTC +8)</datetime-zoneoffset>
</datetime-zoneinfo>
<datetime-datetime>
<datetime-date date="2020-06-02">2020-06-02</datetime-date>
<datetime-time time="11:30">11:30</datetime-time>
</datetime-datetime>
</datetime-zone>
<datetime-zone>
<datetime-zoneinfo>
<datetime-zonename>Singapore</datetime-zonename>
<datetime-zoneoffset>(UTC +8)</datetime-zoneoffset>
</datetime-zoneinfo>
<datetime-datetime>
<datetime-date date="2020-06-02">2020-06-02</datetime-date>
<datetime-time time="11:30">11:30</datetime-time>
</datetime-datetime>
</datetime-zone>
</main>
<footer>
<ul class="list-inline">
<!--
--><li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
-->
</ul>
</footer>
<script src="/js/third-party/luxon.min.js"></script>
<script src="/js/interactive.js" async></script>
</body>
</html>