ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>No connection — ThreadCount</title>
|
||||
<style>
|
||||
/* Bundled with the app, so it has to stand on its own: no web fonts, no network, no images.
|
||||
Archivo is the brand face; the system stack is what actually renders here. */
|
||||
:root { --paper:#f3f2f2; --ink:#201e1d; --accent:#ec3013; --n600:#7a7573; --n700:#57534f; }
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body {
|
||||
font-family:"Archivo",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
background:var(--paper); color:var(--ink);
|
||||
min-height:100dvh; display:flex; flex-direction:column;
|
||||
padding-top:env(safe-area-inset-top); padding-bottom:env(safe-area-inset-bottom);
|
||||
}
|
||||
header { height:56px; flex:0 0 56px; background:var(--ink); color:var(--paper);
|
||||
display:flex; align-items:center; gap:10px; padding:0 16px; }
|
||||
.sq { width:15px; height:15px; background:var(--accent); flex:none; }
|
||||
.wm { font-weight:800; font-size:17px; letter-spacing:-.01em; }
|
||||
.rule { height:4px; background:var(--accent); }
|
||||
main { flex:1; padding:36px 20px; }
|
||||
h1 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
|
||||
.mark { width:56px; height:4px; background:var(--accent); margin:18px 0 0; }
|
||||
p { font-size:15.5px; line-height:1.65; color:var(--n700); margin-top:20px; max-width:44ch; }
|
||||
ul { margin-top:18px; padding:0; list-style:none; border-top:2px solid var(--ink); }
|
||||
li { display:flex; gap:11px; padding:12px 0; border-bottom:1px solid #cfcccb;
|
||||
font-size:14.5px; line-height:1.5; color:var(--n700); }
|
||||
li i { width:7px; height:7px; background:var(--accent); flex:none; margin-top:7px; }
|
||||
button {
|
||||
width:100%; min-height:64px; border:0; background:var(--accent); color:#fff;
|
||||
font-family:inherit; font-weight:800; font-size:14px; letter-spacing:.08em;
|
||||
text-transform:uppercase; text-align:left; padding:0 20px; cursor:pointer;
|
||||
display:flex; align-items:center; gap:12px;
|
||||
}
|
||||
button:active { background:#b8240e; }
|
||||
.foot { font-size:13px; color:var(--n600); padding:0 20px 22px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><span class="sq"></span><span class="wm">ThreadCount</span></header>
|
||||
<div class="rule"></div>
|
||||
<main>
|
||||
<h1>No connection.</h1>
|
||||
<div class="mark"></div>
|
||||
<p>ThreadCount can’t reach your linen room’s records, so it can’t show you what you’re
|
||||
holding or send a request right now — nothing is being saved up in the background.</p>
|
||||
<ul>
|
||||
<li><i></i>Check the ward’s wifi, or step somewhere with signal.</li>
|
||||
<li><i></i>Nothing you had already sent is lost. A request that went through is with your
|
||||
manager, and it will be here when you get back.</li>
|
||||
<li><i></i>If the wifi is fine and this keeps happening, it’s our end — the contact form
|
||||
on threadcount.tech reaches the person who wrote it.</li>
|
||||
</ul>
|
||||
</main>
|
||||
<button id="again">Try again <span aria-hidden="true">→</span></button>
|
||||
<div class="foot" id="stamp"></div>
|
||||
<script>
|
||||
/* "Try again" has to leave this page. Capacitor serves it from the app's own origin
|
||||
(https://localhost/error.html), so location.reload() re-requests a local file that always
|
||||
loads — the same screen comes back no matter how good the signal is, and the only way out is
|
||||
to force-quit the app. index.html records where it was headed under the same origin, so this
|
||||
goes back there instead; if there is nothing recorded, the app's home screen will do. */
|
||||
var SITE = "https://threadcount.tech";
|
||||
try { var s = localStorage.getItem("tc.server"); if (s && /^https:\/\/[a-z0-9.-]+(:[0-9]+)?$/i.test(s)) SITE = s; } catch (e) { /* private mode is fine */ }
|
||||
var HOME = "/my";
|
||||
document.getElementById('again').addEventListener('click', function () {
|
||||
var path = HOME;
|
||||
try { path = localStorage.getItem('tc.lastpath') || HOME; } catch (e) { /* private mode is fine */ }
|
||||
// A stored value only ever came from this app, but it is still concatenated onto a URL, so it
|
||||
// is checked: one leading slash, and no second one that would make it another host entirely.
|
||||
if (path.charAt(0) !== '/' || path.charAt(1) === '/') path = HOME;
|
||||
location.replace(SITE + path);
|
||||
});
|
||||
|
||||
// A timestamp turns "it says no connection" into something answerable over the phone.
|
||||
try {
|
||||
document.getElementById('stamp').textContent =
|
||||
'Last tried ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) { /* nothing to show is fine */ }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,335 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>ThreadCount Staff</title>
|
||||
<style>
|
||||
/* The staff app's bundled shell — the same first run as the counter app, with the same
|
||||
footage. Everything it needs is beside it on the device, which is why the video plays the
|
||||
instant the app opens rather than crawling over ward wifi on someone's first launch. */
|
||||
:root {
|
||||
--ink:#201e1d; --paper:#f3f2f2; --accent:#ec3013; --accent-700:#b8240e;
|
||||
--n400:#b5b1af; --n300:#d6d3d2; --n700:#57534f;
|
||||
--font:"Archivo",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
|
||||
html, body { height:100%; overflow:hidden; background:var(--ink); }
|
||||
body { font-family:var(--font); color:var(--paper); }
|
||||
[hidden] { display:none !important; }
|
||||
|
||||
/* ---------- 1. Splash: the mark draws itself, so the loading indicator IS the logo. */
|
||||
#splash { position:fixed; inset:0; background:var(--ink); display:flex; flex-direction:column;
|
||||
align-items:flex-start; justify-content:center; padding:0 28px; z-index:3; }
|
||||
.bars { display:flex; flex-direction:column; gap:21px; }
|
||||
.bar { height:28px; width:0; background:var(--paper); animation:draw 420ms cubic-bezier(.2,.7,.3,1) forwards; }
|
||||
.bar.a { background:var(--accent); }
|
||||
/* Widths and the 14-step come from tc-icon.svg (60/48/34/22 in a 100 box) scaled 3.5x. */
|
||||
.b1 { --w:210px; animation-delay:0ms; }
|
||||
.b2 { --w:168px; animation-delay:150ms; }
|
||||
.b3 { --w:119px; animation-delay:300ms; }
|
||||
.b4 { --w:77px; animation-delay:450ms; }
|
||||
@keyframes draw { from { width:0; } to { width:var(--w); } }
|
||||
|
||||
.rise { opacity:0; transform:translateY(10px); animation:rise 450ms cubic-bezier(.2,.7,.3,1) forwards; }
|
||||
@keyframes rise { to { opacity:1; transform:translateY(0); } }
|
||||
#splash .wm { margin-top:52px; width:236px; animation-delay:880ms; }
|
||||
#splash .wm img { width:100%; display:block; }
|
||||
#splash .strap { margin-top:18px; animation-delay:1060ms; }
|
||||
#splash .strap hr { border:0; border-top:2px solid rgba(243,242,242,.28); }
|
||||
#splash .strap span { display:block; margin-top:14px; font-size:12px; letter-spacing:.14em;
|
||||
text-transform:uppercase; color:var(--n400); }
|
||||
|
||||
/* ---------- 2. Welcome: video, scrim, wordmark, bottom block. */
|
||||
#welcome { position:fixed; inset:0; z-index:2; }
|
||||
#welcome video, #welcome .poster {
|
||||
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
|
||||
}
|
||||
/* The background belongs to the poster, not the video. A background on a replaced element is
|
||||
painted whether or not there is a frame to draw over it, and the video sits above the poster
|
||||
— so an opaque one hides the poster at exactly the moment it is meant to stand in. */
|
||||
#welcome .poster { background:var(--ink); }
|
||||
/* The scrim is load-bearing: it is what makes the type legible over moving footage. */
|
||||
#welcome .scrim { position:absolute; inset:0; background:linear-gradient(180deg,
|
||||
rgba(32,30,29,.62) 0%, rgba(32,30,29,.2) 34%, rgba(32,30,29,.93) 100%); }
|
||||
#welcome .top { position:absolute; top:calc(24px + env(safe-area-inset-top,0px)); left:24px; }
|
||||
#welcome .top img { height:19px; display:block; }
|
||||
#welcome .bottom { position:absolute; left:0; right:0;
|
||||
bottom:calc(26px + env(safe-area-inset-bottom,0px)); padding:0 24px; }
|
||||
.kicker { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--n300); }
|
||||
h1 { font-weight:800; font-size:38px; line-height:1.0; letter-spacing:-.03em; color:var(--paper); margin-top:12px; }
|
||||
.lede { font-size:15px; line-height:1.6; color:var(--n300); margin-top:18px; max-width:34ch; }
|
||||
/* The stepped rules, in place of a single divider. */
|
||||
.steps { margin:22px 0 24px; display:flex; flex-direction:column; gap:14px; }
|
||||
.steps i { display:block; height:4px; }
|
||||
.steps i:nth-child(1) { width:190px; background:var(--accent); }
|
||||
.steps i:nth-child(2) { width:132px; background:rgba(243,242,242,.5); }
|
||||
.steps i:nth-child(3) { width:84px; background:rgba(243,242,242,.3); }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:2px; }
|
||||
.btn { height:62px; display:flex; align-items:center; padding:0 16px; border:2px solid transparent;
|
||||
font-family:inherit; font-weight:800; font-size:13px; letter-spacing:.08em;
|
||||
text-transform:uppercase; text-align:left; cursor:pointer; }
|
||||
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.btn-primary:active { background:var(--accent-700); border-color:var(--accent-700); }
|
||||
.btn-ghost { background:transparent; border-color:rgba(243,242,242,.85); color:var(--paper); }
|
||||
.btn-ghost:active { background:rgba(243,242,242,.15); }
|
||||
/* The footage loops for as long as this screen is up, so there has to be a way to stop it
|
||||
(WCAG 2.2.2). The chip sits opposite the wordmark and is set like the rest of the type. */
|
||||
#welcome .motion { position:absolute; top:calc(12px + env(safe-area-inset-top,0px)); right:16px;
|
||||
min-width:92px; height:44px; padding:0 14px; background:transparent;
|
||||
border:2px solid rgba(243,242,242,.55); color:var(--paper);
|
||||
font-family:inherit; font-weight:800; font-size:11px; letter-spacing:.14em;
|
||||
text-transform:uppercase; cursor:pointer; }
|
||||
#welcome .motion:active { background:rgba(243,242,242,.15); }
|
||||
|
||||
/* Someone who has asked the OS for less movement gets the mark drawn rather than drawing, and a
|
||||
still welcome screen: the video stays on its poster until they ask for it. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar { animation:none; width:var(--w); }
|
||||
.rise { animation:none; opacity:1; transform:none; }
|
||||
}
|
||||
|
||||
/* ---------- offline, shown instead of sending anyone to a browser error page */
|
||||
#offline { position:fixed; inset:0; z-index:4; background:var(--paper); color:var(--ink);
|
||||
padding:calc(32px + env(safe-area-inset-top,0px)) 24px 24px; }
|
||||
#offline h2 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
|
||||
#offline .mark { width:56px; height:4px; background:var(--accent); margin-top:18px; }
|
||||
#offline p { font-size:15.5px; line-height:1.65; color:var(--n700); margin-top:20px; max-width:44ch; }
|
||||
#offline button { margin-top:28px; width:100%; }
|
||||
#offline .btn-primary { border-color:var(--accent); }
|
||||
|
||||
/* ---------- which server: the hosted service, or a room's own (Community edition) */
|
||||
.srvline { display:block; margin-top:14px; background:none; border:0; padding:6px 0; text-align:left;
|
||||
color:rgba(243,242,242,.7); font-family:inherit; font-size:11px; font-weight:800;
|
||||
letter-spacing:.12em; text-transform:uppercase; cursor:pointer; }
|
||||
#serverpick { position:fixed; inset:0; z-index:5; background:var(--paper); color:var(--ink); overflow:auto;
|
||||
padding:calc(32px + env(safe-area-inset-top,0px)) 24px 24px; }
|
||||
#serverpick h2 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
|
||||
#serverpick .mark { width:56px; height:4px; background:var(--accent); margin-top:18px; }
|
||||
#serverpick p { font-size:15px; line-height:1.6; color:#57534f; margin-top:16px; max-width:44ch; }
|
||||
#serverpick .srvopt { display:block; width:100%; height:auto; line-height:1.25; margin-top:10px; text-align:left; color:var(--ink);
|
||||
border:2px solid #cfcbc9; background:#fff; padding:14px 16px; }
|
||||
#serverpick .srvopt[aria-pressed="true"] { border-color:var(--ink); }
|
||||
#serverpick .srvopt small { display:block; font-weight:500; font-size:12px; letter-spacing:0; text-transform:none; color:#57534f; margin-top:2px; }
|
||||
#serverpick label { display:block; margin-top:18px; font-size:11px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; color:#57534f; }
|
||||
#serverpick input { display:block; width:100%; margin-top:8px; min-height:52px; padding:0 14px; border:2px solid #cfcbc9;
|
||||
border-radius:0; font:inherit; font-size:16px; background:#fff; color:var(--ink); }
|
||||
#serverpick .srvmsg { min-height:1.6em; font-weight:700; color:var(--ink); }
|
||||
#serverpick .btn { width:100%; margin-top:14px; }
|
||||
#serverpick .btn-primary { border-color:var(--accent); }
|
||||
#serverpick .btn-ghost { color:var(--ink); border-color:#cfcbc9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section id="splash" aria-label="ThreadCount Staff">
|
||||
<div class="bars" aria-hidden="true">
|
||||
<span class="bar a b1"></span><span class="bar b2"></span><span class="bar b3"></span><span class="bar b4"></span>
|
||||
</div>
|
||||
<div class="wm rise"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
|
||||
<div class="strap rise"><hr><span>Your uniform, tracked</span></div>
|
||||
</section>
|
||||
|
||||
<section id="welcome" hidden>
|
||||
<img class="poster" id="poster" src="media/welcome-poster.jpg" alt="">
|
||||
<!-- No autoplay attribute: playback is started from the script, which is what lets it be held
|
||||
back for someone who has asked the OS for reduced motion. -->
|
||||
<video id="vid" src="media/welcome.mp4" muted loop playsinline preload="auto" tabindex="-1" aria-hidden="true"></video>
|
||||
<div class="scrim"></div>
|
||||
<div class="top"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
|
||||
<button class="motion" id="motion" type="button" aria-label="Pause background video">Pause</button>
|
||||
<div class="bottom">
|
||||
<div class="kicker">Kit · Requests · Orders</div>
|
||||
<h1>What you’ve got.<br>What’s on the way.</h1>
|
||||
<div class="steps" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="signin">Sign in</button>
|
||||
<button class="btn btn-ghost" id="code">I have a code</button>
|
||||
</div>
|
||||
<button class="srvline" id="srvline" type="button">Server: threadcount.tech · Change</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="serverpick" hidden>
|
||||
<h2>Which server?</h2>
|
||||
<div class="mark"></div>
|
||||
<p>Most rooms use ThreadCount’s hosted service. A room running the Community edition on
|
||||
its own server enters that server’s address here, once, on each phone.</p>
|
||||
<button class="btn srvopt" id="srvhosted" type="button" aria-pressed="true">ThreadCount hosted<small>threadcount.tech</small></button>
|
||||
<button class="btn srvopt" id="srvown" type="button" aria-pressed="false">Self-hosted<small>your own server, over https</small></button>
|
||||
<div id="srvform" hidden>
|
||||
<label for="srvhost">Server address</label>
|
||||
<input id="srvhost" type="url" inputmode="url" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="uniforms.example.health">
|
||||
<button class="btn btn-primary" id="srvsave" type="button">Check and use this server</button>
|
||||
</div>
|
||||
<p class="srvmsg" id="srvmsg" role="status"></p>
|
||||
<button class="btn btn-ghost" id="srvback" type="button">Back</button>
|
||||
</section>
|
||||
|
||||
<section id="offline" hidden>
|
||||
<h2>No connection.</h2>
|
||||
<div class="mark"></div>
|
||||
<p>ThreadCount can’t reach your linen room’s records, so it can’t show you what
|
||||
you’re holding or send a request. Nothing is being saved up in the background.</p>
|
||||
<p>Check the ward’s wifi, or step somewhere with signal.</p>
|
||||
<button class="btn btn-primary" id="retry">Try again</button>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
/* Which server. The hosted service unless this phone was pointed at a self-hosted one (the
|
||||
Community edition runs on a room's own server): saved here, on the app's own origin, and told to
|
||||
the shell through a navigation MainActivity intercepts, so the native side can register the
|
||||
bridge for it too. Only an https origin with no path is ever accepted. */
|
||||
var DEFAULT_SITE = "https://threadcount.tech";
|
||||
var SERVER_KEY = "tc.server";
|
||||
var SITE = DEFAULT_SITE;
|
||||
try {
|
||||
var savedSite = localStorage.getItem(SERVER_KEY);
|
||||
if (savedSite && /^https:\/\/[a-z0-9.-]+(:[0-9]+)?$/i.test(savedSite)) SITE = savedSite;
|
||||
} catch (e) { /* private mode is fine */ }
|
||||
var SPLASH_MS = 1500; // the mark finishes at ~1510ms; holding longer just delays the app
|
||||
var SEEN = "tc.staff.onboarded";
|
||||
var LAST = "tc.lastpath"; // read by error.html, which is bundled beside this file and shares its origin
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function show(id) { ["splash", "welcome", "offline", "serverpick"].forEach(function (s) { $(s).hidden = s !== id; }); }
|
||||
|
||||
function seen() { try { return localStorage.getItem(SEEN) === "1"; } catch (e) { return false; } }
|
||||
function markSeen() { try { localStorage.setItem(SEEN, "1"); } catch (e) { /* private mode is fine */ } }
|
||||
|
||||
/* Reaching the site is checked before navigating, so someone with no signal gets the offline
|
||||
screen rather than a browser error page they can't do anything with. */
|
||||
function reachable() {
|
||||
if (navigator.onLine === false) return Promise.resolve(false);
|
||||
return new Promise(function (resolve) {
|
||||
var done = false;
|
||||
var t = setTimeout(function () { if (!done) { done = true; resolve(false); } }, 6000);
|
||||
fetch(SITE + "/api/app-info", { method: "GET", cache: "no-store", mode: "no-cors" })
|
||||
.then(function () { if (!done) { done = true; clearTimeout(t); resolve(true); } })
|
||||
.catch(function () { if (!done) { done = true; clearTimeout(t); resolve(false); } });
|
||||
});
|
||||
}
|
||||
|
||||
function go(path) {
|
||||
markSeen();
|
||||
// The bundled error page is served from this same origin, so this is the one place that can tell
|
||||
// it where the app was headed. Without it its "Try again" has nowhere to go but back to itself.
|
||||
try { localStorage.setItem(LAST, path); } catch (e) { /* private mode is fine */ }
|
||||
// Fail open. A probe can fail for reasons that have nothing to do with the ward's wifi — a
|
||||
// proxy that dislikes cross-origin requests, a captive portal, a WebView quirk — and refusing
|
||||
// to open the app because one request was awkward is worse than letting it try.
|
||||
if (navigator.onLine === false) { window.__pending = path; show("offline"); return; }
|
||||
reachable().then(function (ok) {
|
||||
if (ok || navigator.onLine !== false) location.replace(SITE + path);
|
||||
else { window.__pending = path; show("offline"); }
|
||||
});
|
||||
}
|
||||
|
||||
// The video is bundled, so it is ready immediately; the poster only covers the first frame or two.
|
||||
/* The server chooser. The address is saved on this origin and handed to the shell by a navigation
|
||||
MainActivity intercepts (never in a browser, where it would really navigate). The server is
|
||||
checked first: it has to answer /api/app-info as a ThreadCount server, over https. */
|
||||
function hostOf(site) { return site.replace(/^https:\/\//, ""); }
|
||||
function isNative() { try { return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()); } catch (e) { return false; } }
|
||||
function paintServer() {
|
||||
var own = SITE !== DEFAULT_SITE;
|
||||
$("srvline").textContent = "Server: " + hostOf(SITE) + " · Change";
|
||||
$("srvhosted").setAttribute("aria-pressed", own ? "false" : "true");
|
||||
$("srvown").setAttribute("aria-pressed", own ? "true" : "false");
|
||||
$("srvform").hidden = !own;
|
||||
if (own) $("srvhost").value = hostOf(SITE);
|
||||
}
|
||||
function tellShell(origin) { if (isNative()) location.href = "https://localhost/__server?origin=" + encodeURIComponent(origin || ""); }
|
||||
function saveServer(site) {
|
||||
try { if (site === DEFAULT_SITE) localStorage.removeItem(SERVER_KEY); else localStorage.setItem(SERVER_KEY, site); } catch (e) { /* private mode is fine */ }
|
||||
SITE = site;
|
||||
tellShell(site === DEFAULT_SITE ? "" : site);
|
||||
paintServer();
|
||||
}
|
||||
$("srvline").addEventListener("click", function () { $("srvmsg").textContent = ""; paintServer(); show("serverpick"); });
|
||||
$("srvhosted").addEventListener("click", function () { saveServer(DEFAULT_SITE); $("srvmsg").textContent = "Using threadcount.tech."; });
|
||||
$("srvown").addEventListener("click", function () { $("srvform").hidden = false; $("srvown").setAttribute("aria-pressed", "true"); $("srvhosted").setAttribute("aria-pressed", "false"); $("srvhost").focus(); });
|
||||
$("srvback").addEventListener("click", function () { show("welcome"); });
|
||||
$("srvsave").addEventListener("click", function () {
|
||||
var raw = ($("srvhost").value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{2,5})?$/.test(raw)) { $("srvmsg").textContent = "Enter the server’s address, like uniforms.example.health"; return; }
|
||||
var origin = "https://" + raw;
|
||||
$("srvmsg").textContent = "Checking " + raw + "…";
|
||||
var done = false;
|
||||
var t = setTimeout(function () { if (!done) { done = true; $("srvmsg").textContent = "No answer from " + raw + ". Is it reachable from this phone, over https?"; } }, 8000);
|
||||
fetch(origin + "/api/app-info", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (done) return; done = true; clearTimeout(t);
|
||||
if (!j || j.product !== "threadcount") { $("srvmsg").textContent = raw + " answered, but it is not a ThreadCount server."; return; }
|
||||
saveServer(origin);
|
||||
$("srvmsg").textContent = "Using " + raw + (j.edition ? " (" + j.edition + " edition)." : ".");
|
||||
}).catch(function () { if (done) return; done = true; clearTimeout(t); $("srvmsg").textContent = "Could not reach " + raw + " over https."; });
|
||||
});
|
||||
paintServer();
|
||||
|
||||
var vid = $("vid");
|
||||
vid.addEventListener("playing", function () { $("poster").style.display = "none"; });
|
||||
|
||||
var reduce = false;
|
||||
try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) { /* old WebView: assume not */ }
|
||||
|
||||
/* Started when the welcome is actually on screen, not at script load.
|
||||
For the first 1.5 seconds #welcome is `hidden`, so the element is display:none, and a WebView
|
||||
is entitled to refuse or stall playback for something that isn't in the layout — the promise
|
||||
rejects, the poster stays, and it looks like the video is missing. */
|
||||
function playWelcome() {
|
||||
// Nobody who has asked the OS for less movement gets a loop started for them. The poster is a
|
||||
// still from the same footage, and the button turns it on for anyone who does want it.
|
||||
if (reduce) return;
|
||||
// On the frame after the welcome is laid out, rather than in the same tick as `hidden = false`.
|
||||
// Chrome will not start a video it considers off screen, and it does not come back to retry.
|
||||
requestAnimationFrame(function () {
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
function attempt() {
|
||||
var p = vid.play();
|
||||
if (p && p.catch) p.catch(function () {
|
||||
// One more try once there is definitely something decoded to show. After that the poster
|
||||
// stands in, which is a still from the same footage and reads as intended.
|
||||
vid.addEventListener("canplay", retry);
|
||||
});
|
||||
}
|
||||
|
||||
function retry() {
|
||||
vid.removeEventListener("canplay", retry);
|
||||
var p = vid.play();
|
||||
if (p && p.catch) p.catch(function () { /* refused: the poster stands in, as designed */ });
|
||||
}
|
||||
|
||||
// The label follows the video's own state rather than the click, so it stays honest when playback
|
||||
// is refused, or when the loop is stopped by anything other than this button.
|
||||
function motionLabel() {
|
||||
var word = vid.paused ? "Play" : "Pause";
|
||||
$("motion").textContent = word;
|
||||
$("motion").setAttribute("aria-label", word + " background video");
|
||||
}
|
||||
vid.addEventListener("play", motionLabel);
|
||||
vid.addEventListener("pause", motionLabel);
|
||||
$("motion").addEventListener("click", function () {
|
||||
if (vid.paused) attempt();
|
||||
else vid.pause();
|
||||
});
|
||||
motionLabel();
|
||||
|
||||
$("signin").addEventListener("click", function () { go("/my/signin"); });
|
||||
// Same screen, opened on the other tab — somebody holding a printed slip should not have to find
|
||||
// it themselves on their first launch.
|
||||
$("code").addEventListener("click", function () { go("/my/signin?code=1"); });
|
||||
$("retry").addEventListener("click", function () { go(window.__pending || "/my"); });
|
||||
|
||||
setTimeout(function () {
|
||||
// Someone who has been here before goes straight to their record; the welcome screen is a
|
||||
// first-run thing, not a toll gate on every launch.
|
||||
if (seen()) go("/my");
|
||||
else { show("welcome"); playWelcome(); }
|
||||
}, SPLASH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
Reference in New Issue
Block a user