Prototype interface design
parent
8def2eeaeb
commit
3839df846c
|
@ -0,0 +1 @@
|
|||
datetime.link
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package main
|
||||
|
||||
//go:generate go run scripts/bcp47timezone.go
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/serverwentdown/datetime.link
|
||||
|
||||
go 1.14
|
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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>
|
Loading…
Reference in New Issue