├── .gitignore ├── LICENSE ├── README.md └── server ├── manifest.json ├── server.go ├── service-worker.js ├── static ├── favicon.ico ├── images │ ├── clear.png │ ├── cloudy-scattered-showers.png │ ├── cloudy.png │ ├── cloudy_s_sunny.png │ ├── fog.png │ ├── ic_add_white_24px.svg │ ├── ic_refresh_white_24px.svg │ ├── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-256x256.png │ │ └── icon-32x32.png │ ├── partly-cloudy.png │ ├── rain.png │ ├── scattered-showers.png │ ├── sleet.png │ ├── snow.png │ ├── thunderstorm.png │ └── wind.png ├── scripts │ └── app.js └── styles │ └── inline.css └── tmpl ├── edit.html ├── index.html └── view.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Progressive Web App with Golang server 2 | 3 | [Progressive Web App](https://en.wikipedia.org/wiki/Progressive_Web_Apps) example written in JS with Golang as web server. 4 | 5 | Based on [Your first Progressive Web App](https://codelabs.developers.google.com/codelabs/your-first-pwapp/) 6 | code lab from Google. 7 | 8 | ## How to start 9 | ``` 10 | git clone https://github.com/valasek/golang-pwa 11 | cd server 12 | go run server.go 13 | ``` 14 | # Your first Progressive Web App code lab from Google 15 | 16 | ## What you’ll learn 17 | * How to design and construct an app using the “app shell” method 18 | * How to make your app work offline 19 | * How to store data for use offline later 20 | 21 | ## What you’ll need 22 | * Chrome 52 or above, though any browser that supports service workers and `cache.addAll()` will work 23 | * [Web Server for Chrome](https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb), or use your own web server of choice. 24 | * The [sample code](https://github.com/googlecodelabs/your-first-pwapp/archive/master.zip) 25 | * A text editor 26 | * Basic knowledge of HTML, CSS and JavaScript 27 | * (Optional) Node is required in the last step to deploy to Firebase 28 | -------------------------------------------------------------------------------- /server/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Weather", 3 | "short_name": "Weather", 4 | "icons": [{ 5 | "src": "static/images/icons/icon-128x128.png", 6 | "sizes": "128x128", 7 | "type": "image/png" 8 | }, { 9 | "src": "static/images/icons/icon-144x144.png", 10 | "sizes": "144x144", 11 | "type": "image/png" 12 | }, { 13 | "src": "static/images/icons/icon-152x152.png", 14 | "sizes": "152x152", 15 | "type": "image/png" 16 | }, { 17 | "src": "static/images/icons/icon-192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, { 21 | "src": "static/images/icons/icon-256x256.png", 22 | "sizes": "256x256", 23 | "type": "image/png" 24 | }], 25 | "start_url": "/index.html", 26 | "display": "standalone", 27 | "background_color": "#3E4EB8", 28 | "theme_color": "#2F3BA2" 29 | } -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "html/template" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "regexp" 10 | "log" 11 | ) 12 | 13 | type Page struct { 14 | Title string 15 | Body []byte 16 | } 17 | 18 | var templates = template.Must(template.ParseFiles("tmpl/index.html", "tmpl/edit.html", "tmpl/view.html")) 19 | var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$") 20 | 21 | func (p *Page) save() error { 22 | filename := p.Title + ".txt" 23 | return ioutil.WriteFile(filename, p.Body, 0600) 24 | } 25 | 26 | func getTitle(w http.ResponseWriter, r *http.Request) (string, error) { 27 | m := validPath.FindStringSubmatch(r.URL.Path) 28 | if m == nil { 29 | http.NotFound(w, r) 30 | return "", errors.New("Invalid Page Title") 31 | } 32 | return m[2], nil 33 | } 34 | 35 | func loadPage(title string) (*Page, error) { 36 | filename := title + ".txt" 37 | body, err := ioutil.ReadFile(filename) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return &Page{Title: title, Body: body}, nil 42 | } 43 | 44 | func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { 45 | err := templates.ExecuteTemplate(w, tmpl+".html", p) 46 | if err != nil { 47 | http.Error(w, err.Error(), http.StatusInternalServerError) 48 | return 49 | } 50 | } 51 | 52 | func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { 53 | return func(w http.ResponseWriter, r *http.Request) { 54 | m := validPath.FindStringSubmatch(r.URL.Path) 55 | if m == nil { 56 | http.NotFound(w, r) 57 | return 58 | } 59 | fn(w, r, m[2]) 60 | } 61 | } 62 | 63 | func rootHandler(w http.ResponseWriter, r *http.Request) { 64 | renderTemplate(w, "index", nil) 65 | } 66 | 67 | func viewHandler(w http.ResponseWriter, r *http.Request, title string) { 68 | p, err := loadPage(title) 69 | if err != nil { 70 | http.Redirect(w, r, "/edit/"+title, http.StatusFound) 71 | return 72 | } 73 | renderTemplate(w, "view", p) 74 | } 75 | 76 | func editHandler(w http.ResponseWriter, r *http.Request, title string) { 77 | p, err := loadPage(title) 78 | if err != nil { 79 | p = &Page{Title: title} 80 | } 81 | renderTemplate(w, "edit", p) 82 | } 83 | 84 | func saveHandler(w http.ResponseWriter, r *http.Request, title string) { 85 | body := r.FormValue("body") 86 | p := &Page{Title: title, Body: []byte(body)} 87 | err := p.save() 88 | if err != nil { 89 | http.Error(w, err.Error(), http.StatusInternalServerError) 90 | return 91 | } 92 | http.Redirect(w, r, "/view/"+title, http.StatusFound) 93 | } 94 | 95 | func sendSW(w http.ResponseWriter, r *http.Request) { 96 | data, err := ioutil.ReadFile("service-worker.js") 97 | if err != nil { 98 | http.Error(w, "Couldn't read file", http.StatusInternalServerError) 99 | return 100 | } 101 | w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 102 | w.Write(data) 103 | } 104 | 105 | func sendManifest(w http.ResponseWriter, r *http.Request) { 106 | data, err := ioutil.ReadFile("manifest.json") 107 | if err != nil { 108 | http.Error(w, "Couldn't read file", http.StatusInternalServerError) 109 | return 110 | } 111 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 112 | w.Write(data) 113 | } 114 | 115 | func main() { 116 | log.SetOutput(os.Stdout) 117 | log.Println("Starting golang server on port :8080") 118 | http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 119 | // handle service worker separatelly from outher html content 120 | http.HandleFunc("/service-worker.js", sendSW) 121 | http.HandleFunc("/manifest.json", sendManifest) 122 | http.HandleFunc("/", rootHandler) 123 | http.HandleFunc("/edit/", makeHandler(editHandler)) 124 | http.HandleFunc("/view/", makeHandler(viewHandler)) 125 | http.HandleFunc("/save/", makeHandler(saveHandler)) 126 | err := http.ListenAndServe(":8080", nil) 127 | if err != nil { 128 | log.Fatal(err) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /server/service-worker.js: -------------------------------------------------------------------------------- 1 | var dataCacheName = 'weatherData-v1'; 2 | var cacheName = 'weatherPWA-step-6-1'; 3 | var filesToCache = [ 4 | '/', 5 | '/index.html', 6 | '/scripts/app.js', 7 | '/styles/inline.css', 8 | '/images/clear.png', 9 | '/images/cloudy-scattered-showers.png', 10 | '/images/cloudy.png', 11 | '/images/fog.png', 12 | '/images/ic_add_white_24px.svg', 13 | '/images/ic_refresh_white_24px.svg', 14 | '/images/partly-cloudy.png', 15 | '/images/rain.png', 16 | '/images/scattered-showers.png', 17 | '/images/sleet.png', 18 | '/images/snow.png', 19 | '/images/thunderstorm.png', 20 | '/images/wind.png' 21 | ]; 22 | 23 | self.addEventListener('install', function(e) { 24 | console.log('[ServiceWorker] Install'); 25 | e.waitUntil( 26 | caches.open(cacheName).then(function(cache) { 27 | console.log('[ServiceWorker] Caching app shell'); 28 | return cache.addAll(filesToCache); 29 | }) 30 | ); 31 | }); 32 | 33 | self.addEventListener('activate', function(e) { 34 | console.log('[ServiceWorker] Activate'); 35 | e.waitUntil( 36 | caches.keys().then(function(keyList) { 37 | return Promise.all(keyList.map(function(key) { 38 | if (key !== cacheName && key !== dataCacheName) { 39 | console.log('[ServiceWorker] Removing old cache', key); 40 | return caches.delete(key); 41 | } 42 | })); 43 | }) 44 | ); 45 | return self.clients.claim(); 46 | }); 47 | 48 | self.addEventListener('fetch', function(e) { 49 | console.log('[Service Worker] Fetch', e.request.url); 50 | var dataUrl = 'https://query.yahooapis.com/v1/public/yql'; 51 | if (e.request.url.indexOf(dataUrl) > -1) { 52 | /* 53 | * When the request URL contains dataUrl, the app is asking for fresh 54 | * weather data. In this case, the service worker always goes to the 55 | * network and then caches the response. This is called the "Cache then 56 | * network" strategy: 57 | * https://jakearchibald.com/2014/offline-cookbook/#cache-then-network 58 | */ 59 | e.respondWith( 60 | caches.open(dataCacheName).then(function(cache) { 61 | return fetch(e.request).then(function(response){ 62 | cache.put(e.request.url, response.clone()); 63 | return response; 64 | }); 65 | }) 66 | ); 67 | } else { 68 | /* 69 | * The app is asking for app shell files. In this scenario the app uses the 70 | * "Cache, falling back to the network" offline strategy: 71 | * https://jakearchibald.com/2014/offline-cookbook/#cache-falling-back-to-network 72 | */ 73 | e.respondWith( 74 | caches.match(e.request).then(function(response) { 75 | return response || fetch(e.request); 76 | }) 77 | ); 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /server/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/favicon.ico -------------------------------------------------------------------------------- /server/static/images/clear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/clear.png -------------------------------------------------------------------------------- /server/static/images/cloudy-scattered-showers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/cloudy-scattered-showers.png -------------------------------------------------------------------------------- /server/static/images/cloudy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/cloudy.png -------------------------------------------------------------------------------- /server/static/images/cloudy_s_sunny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/cloudy_s_sunny.png -------------------------------------------------------------------------------- /server/static/images/fog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/fog.png -------------------------------------------------------------------------------- /server/static/images/ic_add_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/static/images/ic_refresh_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/static/images/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-128x128.png -------------------------------------------------------------------------------- /server/static/images/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-144x144.png -------------------------------------------------------------------------------- /server/static/images/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-152x152.png -------------------------------------------------------------------------------- /server/static/images/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-192x192.png -------------------------------------------------------------------------------- /server/static/images/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-256x256.png -------------------------------------------------------------------------------- /server/static/images/icons/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/icons/icon-32x32.png -------------------------------------------------------------------------------- /server/static/images/partly-cloudy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/partly-cloudy.png -------------------------------------------------------------------------------- /server/static/images/rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/rain.png -------------------------------------------------------------------------------- /server/static/images/scattered-showers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/scattered-showers.png -------------------------------------------------------------------------------- /server/static/images/sleet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/sleet.png -------------------------------------------------------------------------------- /server/static/images/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/snow.png -------------------------------------------------------------------------------- /server/static/images/thunderstorm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/thunderstorm.png -------------------------------------------------------------------------------- /server/static/images/wind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valasek/golang-pwa/72ce7ad791e7695d14942586c66e4794ba724f70/server/static/images/wind.png -------------------------------------------------------------------------------- /server/static/scripts/app.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | 16 | (function() { 17 | 'use strict'; 18 | 19 | var app = { 20 | isLoading: true, 21 | visibleCards: {}, 22 | selectedCities: [], 23 | spinner: document.querySelector('.loader'), 24 | cardTemplate: document.querySelector('.cardTemplate'), 25 | container: document.querySelector('.main'), 26 | addDialog: document.querySelector('.dialog-container'), 27 | daysOfWeek: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 28 | }; 29 | 30 | 31 | /***************************************************************************** 32 | * 33 | * Event listeners for UI elements 34 | * 35 | ****************************************************************************/ 36 | 37 | document.getElementById('butRefresh').addEventListener('click', function() { 38 | // Refresh all of the forecasts 39 | app.updateForecasts(); 40 | }); 41 | 42 | document.getElementById('butAdd').addEventListener('click', function() { 43 | // Open/show the add new city dialog 44 | app.toggleAddDialog(true); 45 | }); 46 | 47 | document.getElementById('butAddCity').addEventListener('click', function() { 48 | // Add the newly selected city 49 | var select = document.getElementById('selectCityToAdd'); 50 | var selected = select.options[select.selectedIndex]; 51 | var key = selected.value; 52 | var label = selected.textContent; 53 | if (!app.selectedCities) { 54 | app.selectedCities = []; 55 | } 56 | app.getForecast(key, label); 57 | app.selectedCities.push({key: key, label: label}); 58 | app.saveSelectedCities(); 59 | app.toggleAddDialog(false); 60 | }); 61 | 62 | document.getElementById('butAddCancel').addEventListener('click', function() { 63 | // Close the add new city dialog 64 | app.toggleAddDialog(false); 65 | }); 66 | 67 | 68 | /***************************************************************************** 69 | * 70 | * Methods to update/refresh the UI 71 | * 72 | ****************************************************************************/ 73 | 74 | // Toggles the visibility of the add new city dialog. 75 | app.toggleAddDialog = function(visible) { 76 | if (visible) { 77 | app.addDialog.classList.add('dialog-container--visible'); 78 | } else { 79 | app.addDialog.classList.remove('dialog-container--visible'); 80 | } 81 | }; 82 | 83 | // Updates a weather card with the latest weather forecast. If the card 84 | // doesn't already exist, it's cloned from the template. 85 | app.updateForecastCard = function(data) { 86 | var dataLastUpdated = new Date(data.created); 87 | var sunrise = data.channel.astronomy.sunrise; 88 | var sunset = data.channel.astronomy.sunset; 89 | var current = data.channel.item.condition; 90 | var humidity = data.channel.atmosphere.humidity; 91 | var wind = data.channel.wind; 92 | 93 | var card = app.visibleCards[data.key]; 94 | if (!card) { 95 | card = app.cardTemplate.cloneNode(true); 96 | card.classList.remove('cardTemplate'); 97 | card.querySelector('.location').textContent = data.label; 98 | card.removeAttribute('hidden'); 99 | app.container.appendChild(card); 100 | app.visibleCards[data.key] = card; 101 | } 102 | 103 | // Verifies the data provide is newer than what's already visible 104 | // on the card, if it's not bail, if it is, continue and update the 105 | // time saved in the card 106 | var cardLastUpdatedElem = card.querySelector('.card-last-updated'); 107 | var cardLastUpdated = cardLastUpdatedElem.textContent; 108 | if (cardLastUpdated) { 109 | cardLastUpdated = new Date(cardLastUpdated); 110 | // Bail if the card has more recent data then the data 111 | if (dataLastUpdated.getTime() < cardLastUpdated.getTime()) { 112 | return; 113 | } 114 | } 115 | cardLastUpdatedElem.textContent = data.created; 116 | 117 | card.querySelector('.description').textContent = current.text; 118 | card.querySelector('.date').textContent = current.date; 119 | card.querySelector('.current .icon').classList.add(app.getIconClass(current.code)); 120 | card.querySelector('.current .temperature .value').textContent = 121 | Math.round(current.temp); 122 | card.querySelector('.current .sunrise').textContent = sunrise; 123 | card.querySelector('.current .sunset').textContent = sunset; 124 | card.querySelector('.current .humidity').textContent = 125 | Math.round(humidity) + '%'; 126 | card.querySelector('.current .wind .value').textContent = 127 | Math.round(wind.speed); 128 | card.querySelector('.current .wind .direction').textContent = wind.direction; 129 | var nextDays = card.querySelectorAll('.future .oneday'); 130 | var today = new Date(); 131 | today = today.getDay(); 132 | for (var i = 0; i < 7; i++) { 133 | var nextDay = nextDays[i]; 134 | var daily = data.channel.item.forecast[i]; 135 | if (daily && nextDay) { 136 | nextDay.querySelector('.date').textContent = 137 | app.daysOfWeek[(i + today) % 7]; 138 | nextDay.querySelector('.icon').classList.add(app.getIconClass(daily.code)); 139 | nextDay.querySelector('.temp-high .value').textContent = 140 | Math.round(daily.high); 141 | nextDay.querySelector('.temp-low .value').textContent = 142 | Math.round(daily.low); 143 | } 144 | } 145 | if (app.isLoading) { 146 | app.spinner.setAttribute('hidden', true); 147 | app.container.removeAttribute('hidden'); 148 | app.isLoading = false; 149 | } 150 | }; 151 | 152 | 153 | /***************************************************************************** 154 | * 155 | * Methods for dealing with the model 156 | * 157 | ****************************************************************************/ 158 | 159 | /* 160 | * Gets a forecast for a specific city and updates the card with the data. 161 | * getForecast() first checks if the weather data is in the cache. If so, 162 | * then it gets that data and populates the card with the cached data. 163 | * Then, getForecast() goes to the network for fresh data. If the network 164 | * request goes through, then the card gets updated a second time with the 165 | * freshest data. 166 | */ 167 | app.getForecast = function(key, label) { 168 | var statement = 'select * from weather.forecast where woeid=' + key; 169 | var url = 'https://query.yahooapis.com/v1/public/yql?format=json&q=' + 170 | statement; 171 | // TODO add cache logic here 172 | if ('caches' in window) { 173 | /* 174 | * Check if the service worker has already cached this city's weather 175 | * data. If the service worker has the data, then display the cached 176 | * data while the app fetches the latest data. 177 | */ 178 | caches.match(url).then(function(response) { 179 | if (response) { 180 | response.json().then(function updateFromCache(json) { 181 | var results = json.query.results; 182 | results.key = key; 183 | results.label = label; 184 | results.created = json.query.created; 185 | app.updateForecastCard(results); 186 | }); 187 | } 188 | }); 189 | } 190 | 191 | // Fetch the latest data. 192 | var request = new XMLHttpRequest(); 193 | request.onreadystatechange = function() { 194 | if (request.readyState === XMLHttpRequest.DONE) { 195 | if (request.status === 200) { 196 | var response = JSON.parse(request.response); 197 | var results = response.query.results; 198 | results.key = key; 199 | results.label = label; 200 | results.created = response.query.created; 201 | app.updateForecastCard(results); 202 | } 203 | } else { 204 | // Return the initial weather forecast since no data is available. 205 | app.updateForecastCard(initialWeatherForecast); 206 | } 207 | }; 208 | request.open('GET', url); 209 | request.send(); 210 | }; 211 | 212 | // Iterate all of the cards and attempt to get the latest forecast data 213 | app.updateForecasts = function() { 214 | var keys = Object.keys(app.visibleCards); 215 | keys.forEach(function(key) { 216 | app.getForecast(key); 217 | }); 218 | }; 219 | 220 | // Save list of cities to localStorage. 221 | app.saveSelectedCities = function() { 222 | var selectedCities = JSON.stringify(app.selectedCities); 223 | localStorage.selectedCities = selectedCities; 224 | }; 225 | 226 | app.getIconClass = function(weatherCode) { 227 | // Weather codes: https://developer.yahoo.com/weather/documentation.html#codes 228 | weatherCode = parseInt(weatherCode); 229 | switch (weatherCode) { 230 | case 25: // cold 231 | case 32: // sunny 232 | case 33: // fair (night) 233 | case 34: // fair (day) 234 | case 36: // hot 235 | case 3200: // not available 236 | return 'clear-day'; 237 | case 0: // tornado 238 | case 1: // tropical storm 239 | case 2: // hurricane 240 | case 6: // mixed rain and sleet 241 | case 8: // freezing drizzle 242 | case 9: // drizzle 243 | case 10: // freezing rain 244 | case 11: // showers 245 | case 12: // showers 246 | case 17: // hail 247 | case 35: // mixed rain and hail 248 | case 40: // scattered showers 249 | return 'rain'; 250 | case 3: // severe thunderstorms 251 | case 4: // thunderstorms 252 | case 37: // isolated thunderstorms 253 | case 38: // scattered thunderstorms 254 | case 39: // scattered thunderstorms (not a typo) 255 | case 45: // thundershowers 256 | case 47: // isolated thundershowers 257 | return 'thunderstorms'; 258 | case 5: // mixed rain and snow 259 | case 7: // mixed snow and sleet 260 | case 13: // snow flurries 261 | case 14: // light snow showers 262 | case 16: // snow 263 | case 18: // sleet 264 | case 41: // heavy snow 265 | case 42: // scattered snow showers 266 | case 43: // heavy snow 267 | case 46: // snow showers 268 | return 'snow'; 269 | case 15: // blowing snow 270 | case 19: // dust 271 | case 20: // foggy 272 | case 21: // haze 273 | case 22: // smoky 274 | return 'fog'; 275 | case 24: // windy 276 | case 23: // blustery 277 | return 'windy'; 278 | case 26: // cloudy 279 | case 27: // mostly cloudy (night) 280 | case 28: // mostly cloudy (day) 281 | case 31: // clear (night) 282 | return 'cloudy'; 283 | case 29: // partly cloudy (night) 284 | case 30: // partly cloudy (day) 285 | case 44: // partly cloudy 286 | return 'partly-cloudy-day'; 287 | } 288 | }; 289 | 290 | /* 291 | * Fake weather data that is presented when the user first uses the app, 292 | * or when the user has not saved any cities. See startup code for more 293 | * discussion. 294 | */ 295 | var initialWeatherForecast = { 296 | key: '2459115', 297 | label: 'New York, NY', 298 | created: '2016-07-22T01:00:00Z', 299 | channel: { 300 | astronomy: { 301 | sunrise: "5:43 am", 302 | sunset: "8:21 pm" 303 | }, 304 | item: { 305 | condition: { 306 | text: "Windy", 307 | date: "Thu, 21 Jul 2016 09:00 PM EDT", 308 | temp: 56, 309 | code: 24 310 | }, 311 | forecast: [ 312 | {code: 44, high: 86, low: 70}, 313 | {code: 44, high: 94, low: 73}, 314 | {code: 4, high: 95, low: 78}, 315 | {code: 24, high: 75, low: 89}, 316 | {code: 24, high: 89, low: 77}, 317 | {code: 44, high: 92, low: 79}, 318 | {code: 44, high: 89, low: 77} 319 | ] 320 | }, 321 | atmosphere: { 322 | humidity: 56 323 | }, 324 | wind: { 325 | speed: 25, 326 | direction: 195 327 | } 328 | } 329 | }; 330 | // TODO uncomment line below to test app with fake data 331 | //staticapp.updateForecastCard(initialWeatherForecast); 332 | 333 | /************************************************************************ 334 | * 335 | * Code required to start the app 336 | * 337 | * NOTE: To simplify this codelab, we've used localStorage. 338 | * localStorage is a synchronous API and has serious performance 339 | * implications. It should not be used in production applications! 340 | * Instead, check out IDB (https://www.npmjs.com/package/idb) or 341 | * SimpleDB (https://gist.github.com/inexorabletash/c8069c042b734519680c) 342 | ************************************************************************/ 343 | 344 | app.selectedCities = localStorage.selectedCities; 345 | if (app.selectedCities) { 346 | app.selectedCities = JSON.parse(app.selectedCities); 347 | app.selectedCities.forEach(function(city) { 348 | app.getForecast(city.key, city.label); 349 | }); 350 | } else { 351 | /* The user is using the app for the first time, or the user has not 352 | * saved any cities, so show the user some fake data. A real app in this 353 | * scenario could guess the user's location via IP lookup and then inject 354 | * that data into the page. 355 | */ 356 | app.updateForecastCard(initialWeatherForecast); 357 | app.selectedCities = [ 358 | {key: initialWeatherForecast.key, label: initialWeatherForecast.label} 359 | ]; 360 | app.saveSelectedCities(); 361 | } 362 | 363 | if ('serviceWorker' in navigator) { 364 | navigator.serviceWorker 365 | .register('./service-worker.js') 366 | .then(function() { console.log('Service Worker Registered'); }); 367 | } 368 | })(); 369 | -------------------------------------------------------------------------------- /server/static/styles/inline.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Google Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | * { 18 | box-sizing: border-box; } 19 | 20 | html, body { 21 | padding: 0; 22 | margin: 0; 23 | height: 100%; 24 | width: 100%; 25 | font-family: 'Helvetica', 'Verdana', sans-serif; 26 | font-weight: 400; 27 | font-display: optional; 28 | color: #444; 29 | -webkit-font-smoothing: antialiased; 30 | -moz-osx-font-smoothing: grayscale; } 31 | 32 | html { 33 | overflow: hidden; } 34 | 35 | body { 36 | display: -webkit-box; 37 | display: -webkit-flex; 38 | display: -ms-flexbox; 39 | display: flex; 40 | -webkit-box-orient: vertical; 41 | -webkit-box-direction: normal; 42 | -webkit-flex-direction: column; 43 | -ms-flex-direction: column; 44 | flex-direction: column; 45 | -webkit-flex-wrap: nowrap; 46 | -ms-flex-wrap: nowrap; 47 | flex-wrap: nowrap; 48 | -webkit-box-pack: start; 49 | -webkit-justify-content: flex-start; 50 | -ms-flex-pack: start; 51 | justify-content: flex-start; 52 | -webkit-box-align: stretch; 53 | -webkit-align-items: stretch; 54 | -ms-flex-align: stretch; 55 | align-items: stretch; 56 | -webkit-align-content: stretch; 57 | -ms-flex-line-pack: stretch; 58 | align-content: stretch; 59 | background: #ececec; } 60 | 61 | .header { 62 | width: 100%; 63 | height: 56px; 64 | color: #FFF; 65 | background: #3F51B5; 66 | position: fixed; 67 | font-size: 20px; 68 | box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 2px 9px 1px rgba(0, 0, 0, 0.12), 0 4px 2px -2px rgba(0, 0, 0, 0.2); 69 | padding: 16px 16px 0 16px; 70 | will-change: transform; 71 | display: -webkit-box; 72 | display: -webkit-flex; 73 | display: -ms-flexbox; 74 | display: flex; 75 | -webkit-box-orient: horizontal; 76 | -webkit-box-direction: normal; 77 | -webkit-flex-direction: row; 78 | -ms-flex-direction: row; 79 | flex-direction: row; 80 | -webkit-flex-wrap: nowrap; 81 | -ms-flex-wrap: nowrap; 82 | flex-wrap: nowrap; 83 | -webkit-box-pack: start; 84 | -webkit-justify-content: flex-start; 85 | -ms-flex-pack: start; 86 | justify-content: flex-start; 87 | -webkit-box-align: stretch; 88 | -webkit-align-items: stretch; 89 | -ms-flex-align: stretch; 90 | align-items: stretch; 91 | -webkit-align-content: center; 92 | -ms-flex-line-pack: center; 93 | align-content: center; 94 | -webkit-transition: -webkit-transform 0.233s cubic-bezier(0, 0, 0.21, 1) 0.1s; 95 | transition: -webkit-transform 0.233s cubic-bezier(0, 0, 0.21, 1) 0.1s; 96 | transition: transform 0.233s cubic-bezier(0, 0, 0.21, 1) 0.1s; 97 | transition: transform 0.233s cubic-bezier(0, 0, 0.21, 1) 0.1s, -webkit-transform 0.233s cubic-bezier(0, 0, 0.21, 1) 0.1s; 98 | z-index: 1000; } 99 | .header .headerButton { 100 | width: 24px; 101 | height: 24px; 102 | margin-right: 16px; 103 | text-indent: -30000px; 104 | overflow: hidden; 105 | opacity: 0.54; 106 | -webkit-transition: opacity 0.333s cubic-bezier(0, 0, 0.21, 1); 107 | transition: opacity 0.333s cubic-bezier(0, 0, 0.21, 1); 108 | border: none; 109 | outline: none; 110 | cursor: pointer; } 111 | .header #butRefresh { 112 | background: url(/static/images/ic_refresh_white_24px.svg) center center no-repeat; } 113 | .header #butAdd { 114 | background: url(/static/images/ic_add_white_24px.svg) center center no-repeat; } 115 | 116 | .header__title { 117 | font-weight: 400; 118 | font-size: 20px; 119 | margin: 0; 120 | -webkit-box-flex: 1; 121 | -webkit-flex: 1; 122 | -ms-flex: 1; 123 | flex: 1; } 124 | 125 | .loader { 126 | left: 50%; 127 | top: 50%; 128 | position: fixed; 129 | -webkit-transform: translate(-50%, -50%); 130 | transform: translate(-50%, -50%); } 131 | .loader #spinner { 132 | box-sizing: border-box; 133 | stroke: #673AB7; 134 | stroke-width: 3px; 135 | -webkit-transform-origin: 50%; 136 | transform-origin: 50%; 137 | -webkit-animation: line 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite, rotate 1.6s linear infinite; 138 | animation: line 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite, rotate 1.6s linear infinite; } 139 | 140 | @-webkit-keyframes rotate { 141 | from { 142 | -webkit-transform: rotate(0); 143 | transform: rotate(0); } 144 | to { 145 | -webkit-transform: rotate(450deg); 146 | transform: rotate(450deg); } } 147 | 148 | @keyframes rotate { 149 | from { 150 | -webkit-transform: rotate(0); 151 | transform: rotate(0); } 152 | to { 153 | -webkit-transform: rotate(450deg); 154 | transform: rotate(450deg); } } 155 | 156 | @-webkit-keyframes line { 157 | 0% { 158 | stroke-dasharray: 2, 85.964; 159 | -webkit-transform: rotate(0); 160 | transform: rotate(0); } 161 | 50% { 162 | stroke-dasharray: 65.973, 21.9911; 163 | stroke-dashoffset: 0; } 164 | 100% { 165 | stroke-dasharray: 2, 85.964; 166 | stroke-dashoffset: -65.973; 167 | -webkit-transform: rotate(90deg); 168 | transform: rotate(90deg); } } 169 | 170 | @keyframes line { 171 | 0% { 172 | stroke-dasharray: 2, 85.964; 173 | -webkit-transform: rotate(0); 174 | transform: rotate(0); } 175 | 50% { 176 | stroke-dasharray: 65.973, 21.9911; 177 | stroke-dashoffset: 0; } 178 | 100% { 179 | stroke-dasharray: 2, 85.964; 180 | stroke-dashoffset: -65.973; 181 | -webkit-transform: rotate(90deg); 182 | transform: rotate(90deg); } } 183 | 184 | .main { 185 | padding-top: 60px; 186 | -webkit-box-flex: 1; 187 | -webkit-flex: 1; 188 | -ms-flex: 1; 189 | flex: 1; 190 | overflow-x: hidden; 191 | overflow-y: auto; 192 | -webkit-overflow-scrolling: touch; } 193 | 194 | .dialog-container { 195 | background: rgba(0, 0, 0, 0.57); 196 | position: fixed; 197 | left: 0; 198 | top: 0; 199 | width: 100%; 200 | height: 100%; 201 | opacity: 0; 202 | pointer-events: none; 203 | will-change: opacity; 204 | -webkit-transition: opacity 0.333s cubic-bezier(0, 0, 0.21, 1); 205 | transition: opacity 0.333s cubic-bezier(0, 0, 0.21, 1); } 206 | 207 | .dialog-container--visible { 208 | opacity: 1; 209 | pointer-events: auto; } 210 | 211 | .dialog { 212 | background: #FFF; 213 | border-radius: 2px; 214 | box-shadow: 0 0 14px rgba(0, 0, 0, 0.24), 0 14px 28px rgba(0, 0, 0, 0.48); 215 | min-width: 280px; 216 | position: absolute; 217 | left: 50%; 218 | top: 50%; 219 | -webkit-transform: translate(-50%, -50%) translateY(30px); 220 | transform: translate(-50%, -50%) translateY(30px); 221 | -webkit-transition: -webkit-transform 0.333s cubic-bezier(0, 0, 0.21, 1) 0.05s; 222 | transition: -webkit-transform 0.333s cubic-bezier(0, 0, 0.21, 1) 0.05s; 223 | transition: transform 0.333s cubic-bezier(0, 0, 0.21, 1) 0.05s; 224 | transition: transform 0.333s cubic-bezier(0, 0, 0.21, 1) 0.05s, -webkit-transform 0.333s cubic-bezier(0, 0, 0.21, 1) 0.05s; } 225 | 226 | .dialog > div { 227 | padding-left: 24px; 228 | padding-right: 24px; } 229 | 230 | .dialog-title { 231 | padding-top: 20px; 232 | font-size: 1.25em; } 233 | 234 | .dialog-body { 235 | padding-top: 20px; 236 | padding-bottom: 24px; } 237 | .dialog-body select { 238 | width: 100%; 239 | font-size: 2em; } 240 | 241 | .dialog-buttons { 242 | padding: 8px !important; 243 | float: right; } 244 | 245 | .card { 246 | padding: 16px; 247 | position: relative; 248 | box-sizing: border-box; 249 | background: #fff; 250 | border-radius: 2px; 251 | margin: 16px; 252 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12); } 253 | 254 | .weather-forecast .location { 255 | font-size: 1.75em; } 256 | 257 | .weather-forecast .date, .weather-forecast .description { 258 | font-size: 1.25em; } 259 | 260 | .weather-forecast .current { 261 | display: -webkit-box; 262 | display: -webkit-flex; 263 | display: -ms-flexbox; 264 | display: flex; } 265 | .weather-forecast .current .icon { 266 | width: 128px; 267 | height: 128px; } 268 | .weather-forecast .current .visual { 269 | display: -webkit-box; 270 | display: -webkit-flex; 271 | display: -ms-flexbox; 272 | display: flex; 273 | font-size: 4em; } 274 | .weather-forecast .current .visual .scale { 275 | font-size: 0.5em; 276 | vertical-align: super; } 277 | .weather-forecast .current .visual, .weather-forecast .current .description { 278 | -webkit-box-flex: 1; 279 | -webkit-flex-grow: 1; 280 | -ms-flex-positive: 1; 281 | flex-grow: 1; } 282 | .weather-forecast .current .sunset:before { 283 | content: "Sunset: "; 284 | color: #888; } 285 | .weather-forecast .current .wind:before { 286 | content: "Wind: "; 287 | color: #888; } 288 | .weather-forecast .current .sunrise:before { 289 | content: "Sunrise: "; 290 | color: #888; } 291 | .weather-forecast .current .humidity:before { 292 | content: "Humidity: "; 293 | color: #888; } 294 | .weather-forecast .current .pollen:before { 295 | content: "Pollen Count: "; 296 | color: #888; } 297 | .weather-forecast .current .pcount:before { 298 | content: "Pollen "; 299 | color: #888; } 300 | 301 | .weather-forecast .future { 302 | display: -webkit-box; 303 | display: -webkit-flex; 304 | display: -ms-flexbox; 305 | display: flex; } 306 | .weather-forecast .future .oneday { 307 | -webkit-box-flex: 1; 308 | -webkit-flex-grow: 1; 309 | -ms-flex-positive: 1; 310 | flex-grow: 1; 311 | text-align: center; } 312 | .weather-forecast .future .oneday .icon { 313 | width: 64px; 314 | height: 64px; 315 | margin-left: auto; 316 | margin-right: auto; } 317 | .weather-forecast .future .oneday .temp-high, .weather-forecast .future .oneday .temp-low { 318 | display: inline-block; } 319 | .weather-forecast .future .oneday .temp-low { 320 | color: #888; } 321 | 322 | .weather-forecast .icon { 323 | background-repeat: no-repeat; 324 | background-size: contain; } 325 | .weather-forecast .icon.clear-day { 326 | background-image: url("/static/images/clear.png"); } 327 | .weather-forecast .icon.clear-night { 328 | background-image: url("/static/images/clear.png"); } 329 | .weather-forecast .icon.rain { 330 | background-image: url("/static/images/rain.png"); } 331 | .weather-forecast .icon.snow { 332 | background-image: url("/static/images/snow.png"); } 333 | .weather-forecast .icon.sleet { 334 | background-image: url("/static/images/sleet.png"); } 335 | .weather-forecast .icon.windy { 336 | background-image: url("/static/images/wind.png"); } 337 | .weather-forecast .icon.fog { 338 | background-image: url("/static/images/fog.png"); } 339 | .weather-forecast .icon.cloudy { 340 | background-image: url("/static/images/cloudy.png"); } 341 | .weather-forecast .icon.partly-cloudy-day { 342 | background-image: url("/static/images/partly-cloudy.png"); } 343 | .weather-forecast .icon.partly-cloudy-night { 344 | background-image: url("/static/images/partly-cloudy.png"); } 345 | .weather-forecast .icon.thunderstorms { 346 | background-image: url("/static/images/thunderstorm.png"); } 347 | 348 | @media (max-width: 450px) { 349 | .weather-forecast .date, .weather-forecast .description { 350 | font-size: 0.9em; } 351 | .weather-forecast .current .icon { 352 | width: 96px; 353 | height: 96px; } 354 | .weather-forecast .current .visual { 355 | font-size: 3em; } 356 | .weather-forecast .future .oneday .icon { 357 | width: 32px; 358 | height: 32px; } } 359 | 360 | .mdl-button { 361 | background: transparent; 362 | border: none; 363 | border-radius: 2px; 364 | color: black; 365 | position: relative; 366 | height: 36px; 367 | margin: 0; 368 | min-width: 64px; 369 | padding: 0 16px; 370 | display: inline-block; 371 | font-family: "Roboto", "Helvetica", "Arial", sans-serif; 372 | font-size: 14px; 373 | font-weight: 500; 374 | text-transform: uppercase; 375 | line-height: 1; 376 | letter-spacing: 0; 377 | overflow: hidden; 378 | will-change: box-shadow; 379 | -webkit-transition: box-shadow 0.2s cubic-bezier(0.4, 0, 1, 1), background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s cubic-bezier(0.4, 0, 0.2, 1); 380 | transition: box-shadow 0.2s cubic-bezier(0.4, 0, 1, 1), background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s cubic-bezier(0.4, 0, 0.2, 1); 381 | outline: none; 382 | cursor: pointer; 383 | text-decoration: none; 384 | text-align: center; 385 | line-height: 36px; 386 | vertical-align: middle; } 387 | .mdl-button::-moz-focus-inner { 388 | border: 0; } 389 | .mdl-button:hover { 390 | background-color: rgba(158, 158, 158, 0.2); } 391 | .mdl-button:focus:not(:active) { 392 | background-color: rgba(0, 0, 0, 0.12); } 393 | .mdl-button:active { 394 | background-color: rgba(158, 158, 158, 0.4); } 395 | .mdl-button.mdl-button--colored { 396 | color: #3f51b5; } 397 | .mdl-button.mdl-button--colored:focus:not(:active) { 398 | background-color: rgba(0, 0, 0, 0.12); } 399 | 400 | .mdl-button--primary.mdl-button--primary { 401 | color: #3f51b5; } 402 | .mdl-button--primary.mdl-button--primary .mdl-ripple { 403 | background: white; } 404 | .mdl-button--primary.mdl-button--primary.mdl-button--raised, .mdl-button--primary.mdl-button--primary.mdl-button--fab { 405 | color: white; 406 | background-color: #3f51b5; } 407 | -------------------------------------------------------------------------------- /server/tmpl/edit.html: -------------------------------------------------------------------------------- 1 |

Editing {{.Title}}

2 | 3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
-------------------------------------------------------------------------------- /server/tmpl/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Weather PWA 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |

Weather PWA

43 | 44 | 45 |
46 | 47 |
48 | 145 |
146 | 147 |
148 |
149 |
Add new city
150 |
151 | 162 |
163 |
164 | 165 | 166 |
167 |
168 |
169 | 170 |
171 | 172 | 173 | 174 |
175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /server/tmpl/view.html: -------------------------------------------------------------------------------- 1 |

{{.Title}}

2 | 3 |

[edit]

4 | 5 |
{{printf "%s" .Body}}
--------------------------------------------------------------------------------