Add PWA manifest, service worker, and install prompt

- manifest.webmanifest: standalone display mode, theme #2563eb,
  start_url=/app.html (so users who install land in the app
  shell, not the marketing page), scope=/. Three icons: 192px
  any-purpose, 512px any-purpose, 512px maskable for adaptive
  icons on Android.
- icons/: PNGs generated from favicon.svg.
- sw.js: cache-first for the precached app shell, network-first
  for /api/* and /auth/* (we never serve stale auth or notes).
  Versioned cache name (librenotes-shell-v1) so a SW update
  evicts old assets. skipWaiting + clients.claim so a new SW
  takes over without a manual reload.
- pwa.js: registers the SW on every page and handles
  beforeinstallprompt by showing #install-btn. Hides the button
  again on appinstalled. Defer loaded so it never blocks render.
- All HTML pages link the manifest, set the theme-color meta,
  and load pwa.js. Landing page exposes the install button next
  to the existing CTAs.

Closes #19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:27:32 +02:00
co-authored by Claude Opus 4.7
parent a42b7fa345
commit d4e47f5761
10 changed files with 168 additions and 0 deletions
+3
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes — librenotes</title> <title>Notes — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body class="app-page"> <body class="app-page">
@@ -24,5 +26,6 @@
<script src="/auth-client.js"></script> <script src="/auth-client.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
<script src="/pwa.js" defer></script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+4
View File
@@ -10,6 +10,8 @@
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://librenot.es"> <meta property="og:url" content="https://librenot.es">
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
@@ -34,6 +36,7 @@
<p class="ctas"> <p class="ctas">
<a class="btn primary" href="/login.html">Sign in with email</a> <a class="btn primary" href="/login.html">Sign in with email</a>
<a class="btn ghost" href="https://git.librete.ch/public/librenotes">Self-host it</a> <a class="btn ghost" href="https://git.librete.ch/public/librenotes">Self-host it</a>
<button class="btn ghost" id="install-btn" type="button" hidden>Install app</button>
</p> </p>
</div> </div>
</section> </section>
@@ -69,5 +72,6 @@
<p class="muted">MIT licensed. Built on <a href="https://github.com/alonswartz/notesium">Notesium</a>.</p> <p class="muted">MIT licensed. Built on <a href="https://github.com/alonswartz/notesium">Notesium</a>.</p>
</div> </div>
</footer> </footer>
<script src="/pwa.js" defer></script>
</body> </body>
</html> </html>
+3
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — librenotes</title> <title>Sign in — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body class="auth-page"> <body class="auth-page">
@@ -34,5 +36,6 @@
<p class="footer-link"><a href="/">Back to home</a></p> <p class="footer-link"><a href="/">Back to home</a></p>
</main> </main>
<script src="/login.js"></script> <script src="/login.js"></script>
<script src="/pwa.js" defer></script>
</body> </body>
</html> </html>
@@ -0,0 +1,32 @@
{
"name": "librenotes",
"short_name": "librenotes",
"description": "Open-source multi-tenant notes with bi-directional links.",
"start_url": "/app.html",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+43
View File
@@ -0,0 +1,43 @@
// pwa.js — service worker registration and install prompt.
//
// Loaded on every page. Registers the SW from the root scope so
// it controls the entire site, and surfaces an install button on
// browsers that fire beforeinstallprompt (Chrome / Edge / Android).
(function () {
"use strict";
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js", { scope: "/" })
.catch(function (err) {
console.warn("librenotes: service worker registration failed", err);
});
});
}
let deferredPrompt = null;
window.addEventListener("beforeinstallprompt", function (ev) {
ev.preventDefault();
deferredPrompt = ev;
const btn = document.getElementById("install-btn");
if (btn) {
btn.hidden = false;
btn.addEventListener("click", function () {
if (!deferredPrompt) return;
deferredPrompt.prompt();
deferredPrompt.userChoice.finally(function () {
deferredPrompt = null;
btn.hidden = true;
});
}, { once: true });
}
});
window.addEventListener("appinstalled", function () {
const btn = document.getElementById("install-btn");
if (btn) btn.hidden = true;
deferredPrompt = null;
});
})();
+80
View File
@@ -0,0 +1,80 @@
// sw.js — librenotes service worker.
//
// Strategy:
// - App shell (HTML/CSS/JS in PRECACHE): cache-first.
// This is what makes the page render offline.
// - API calls (/api/*, /auth/*): network-first.
// We never serve stale auth or stale notes from cache. If the
// network is down, the request fails and the app handles it.
// - Other static assets (icons, manifest): cache-first with
// versioned cache name so a deploy of a new SW evicts old.
const VERSION = "v1";
const CACHE = "librenotes-shell-" + VERSION;
const PRECACHE = [
"/",
"/index.html",
"/login.html",
"/verify.html",
"/app.html",
"/style.css",
"/auth-client.js",
"/login.js",
"/verify.js",
"/app.js",
"/manifest.webmanifest",
"/favicon.svg",
];
self.addEventListener("install", function (ev) {
ev.waitUntil(
caches.open(CACHE).then(function (cache) {
return cache.addAll(PRECACHE);
})
);
self.skipWaiting();
});
self.addEventListener("activate", function (ev) {
ev.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.filter(function (k) { return k !== CACHE; })
.map(function (k) { return caches.delete(k); })
);
}).then(function () { return self.clients.claim(); })
);
});
self.addEventListener("fetch", function (ev) {
const req = ev.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Network-first for API and auth endpoints.
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) {
ev.respondWith(
fetch(req).catch(function () {
return new Response("offline", { status: 503, statusText: "offline" });
})
);
return;
}
// Cache-first for everything else (the app shell).
ev.respondWith(
caches.match(req).then(function (cached) {
if (cached) return cached;
return fetch(req).then(function (resp) {
if (resp && resp.status === 200 && resp.type === "basic") {
const copy = resp.clone();
caches.open(CACHE).then(function (cache) { cache.put(req, copy); });
}
return resp;
});
})
);
});
+3
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in — librenotes</title> <title>Signing in — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body class="auth-page"> <body class="auth-page">
@@ -24,5 +26,6 @@
</main> </main>
<script src="/auth-client.js"></script> <script src="/auth-client.js"></script>
<script src="/verify.js"></script> <script src="/verify.js"></script>
<script src="/pwa.js" defer></script>
</body> </body>
</html> </html>