Installable as an app (PWA): manifests, offline-only service worker, icons
This commit is contained in:
parent
b26360e8f3
commit
44708d5f3c
16 changed files with 246 additions and 0 deletions
|
|
@ -3,6 +3,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#1b1e25">
|
||||
<link rel="manifest" href="/admin.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<title>Staff panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
|
|
|||
BIN
internal/web/assets/apple-touch-icon.png
Normal file
BIN
internal/web/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
|
|
@ -165,3 +165,6 @@ function toast(msg) {
|
|||
}
|
||||
|
||||
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`;
|
||||
|
||||
// The service worker makes the site installable as an app (see sw.js).
|
||||
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||
|
|
|
|||
BIN
internal/web/assets/icon-192.png
Normal file
BIN
internal/web/assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
internal/web/assets/icon-512.png
Normal file
BIN
internal/web/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
internal/web/assets/icon-maskable-512.png
Normal file
BIN
internal/web/assets/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
7
internal/web/assets/icon-maskable.svg
Normal file
7
internal/web/assets/icon-maskable.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#14161b"/>
|
||||
<g transform="translate(112 112) scale(9)">
|
||||
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
7
internal/web/assets/icon.svg
Normal file
7
internal/web/assets/icon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#14161b"/>
|
||||
<g transform="translate(64 64) scale(12)">
|
||||
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
55
internal/web/assets/offline.html
Normal file
55
internal/web/assets/offline.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#1b1e25">
|
||||
<!-- Served in place of whatever page failed to load, so a refresh retries that page. -->
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Offline</title>
|
||||
<!-- Self-contained: nothing else is cached, so styles.css and the fonts can't load from here. -->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: #14161b;
|
||||
color: #e6e8ee;
|
||||
font: 16px/1.5 system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
main {
|
||||
max-width: 360px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
background: #1b1e25;
|
||||
border: 1px solid #2e333f;
|
||||
border-radius: 12px;
|
||||
}
|
||||
svg { width: 48px; height: 48px; }
|
||||
h1 { margin: 12px 0 4px; font-size: 22px; }
|
||||
p { margin: 0 0 20px; color: #8a91a0; }
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 8px 18px;
|
||||
background: #fe113d;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<h1>Can't reach the panel</h1>
|
||||
<p>Check your connection. This page tries again by itself every few seconds.</p>
|
||||
<a href="">Try again</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,6 +3,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#1b1e25">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<title>Bans and staff</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#1b1e25">
|
||||
<link rel="manifest" href="/admin.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<title>Staff sign in</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
|
|
|||
23
internal/web/assets/sw.js
Normal file
23
internal/web/assets/sw.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// The service worker is what lets the site be installed as an app. It caches nothing but the
|
||||
// offline page: every request still goes to the network, so nobody sees stale bans or ranks.
|
||||
|
||||
const CACHE = "offline-v1";
|
||||
const OFFLINE = "/static/offline.html";
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.add(OFFLINE)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
// Only page loads get the offline page; API calls fail as they always have, and the pages say so.
|
||||
self.addEventListener("fetch", (e) => {
|
||||
if (e.request.mode !== "navigate") return;
|
||||
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)));
|
||||
});
|
||||
71
internal/web/pwa.go
Normal file
71
internal/web/pwa.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The site installs as an app (a PWA): a web app manifest, plus the service worker in
|
||||
// assets/sw.js. The public site and the staff panel are separate apps, so each opens on its own
|
||||
// page; both cover the whole site, since signing in goes through /auth.
|
||||
|
||||
type manifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Description string `json:"description"`
|
||||
StartURL string `json:"start_url"`
|
||||
Scope string `json:"scope"`
|
||||
Display string `json:"display"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
ThemeColor string `json:"theme_color"`
|
||||
Icons []manifestIcon `json:"icons"`
|
||||
}
|
||||
|
||||
type manifestIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) manifest(staff bool) http.HandlerFunc {
|
||||
m := manifest{
|
||||
ID: "/",
|
||||
Name: s.site.Title,
|
||||
ShortName: s.site.Title,
|
||||
Description: "Live server, bans and staff.",
|
||||
StartURL: "/",
|
||||
Scope: "/",
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#14161b",
|
||||
ThemeColor: "#1b1e25",
|
||||
Icons: []manifestIcon{
|
||||
{Src: "/static/icon-192.png", Sizes: "192x192", Type: "image/png"},
|
||||
{Src: "/static/icon-512.png", Sizes: "512x512", Type: "image/png"},
|
||||
{Src: "/static/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
|
||||
},
|
||||
}
|
||||
if staff {
|
||||
m.ID = "/admin"
|
||||
m.Name = s.site.Title + " staff panel"
|
||||
m.ShortName = s.site.Title + " staff"
|
||||
m.Description = "Bans, mutes, ranks and the live server, for staff."
|
||||
m.StartURL = "/admin"
|
||||
}
|
||||
body, _ := json.Marshal(m)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceWorker serves sw.js from the root, since a worker only controls pages under its own path.
|
||||
func serviceWorker(static fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFileFS(w, r, static, "sw.js")
|
||||
}
|
||||
}
|
||||
46
internal/web/pwa_test.go
Normal file
46
internal/web/pwa_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPWA(t *testing.T) {
|
||||
h := (&Server{site: defaultSite()}).Handler()
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, rec.Code)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
for path, start := range map[string]string{"/manifest.webmanifest": "/", "/admin.webmanifest": "/admin"} {
|
||||
rec := get(path)
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/manifest+json" {
|
||||
t.Errorf("%s: content type %q", path, ct)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if m.StartURL != start || m.ID != start || m.Scope != "/" || m.Display != "standalone" {
|
||||
t.Errorf("%s: %+v", path, m)
|
||||
}
|
||||
// Every icon must exist, or Chrome won't offer to install.
|
||||
for _, icon := range m.Icons {
|
||||
if ct := get(icon.Src).Header().Get("Content-Type"); ct != icon.Type {
|
||||
t.Errorf("%s: %s is %q, manifest says %q", path, icon.Src, ct, icon.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ct := get("/sw.js").Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||
t.Errorf("/sw.js: content type %q", ct)
|
||||
}
|
||||
get("/static/offline.html")
|
||||
}
|
||||
|
|
@ -81,6 +81,9 @@ func (s *Server) Handler() http.Handler {
|
|||
})
|
||||
mux.HandleFunc("GET /admin", s.adminPage(static))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", files))
|
||||
mux.HandleFunc("GET /manifest.webmanifest", s.manifest(false))
|
||||
mux.HandleFunc("GET /admin.webmanifest", s.manifest(true))
|
||||
mux.HandleFunc("GET /sw.js", serviceWorker(static))
|
||||
|
||||
mux.HandleFunc("GET /auth/login", s.login)
|
||||
mux.HandleFunc("GET /auth/callback", s.callback)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue