Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
f62ffc8d92 |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
@@ -14,9 +16,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/boombuler/barcode/qr"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
|||||||
return key.Secret(), key.URL(), nil
|
return key.Secret(), key.URL(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||||
|
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||||
|
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||||
|
// needs whatever theme the page is in.
|
||||||
|
//
|
||||||
|
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||||
|
// this borrows a dependency rather than adding one.
|
||||||
|
func TOTPQRSVG(uri string) (string, error) {
|
||||||
|
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||||
|
}
|
||||||
|
b := code.Bounds()
|
||||||
|
n := b.Dx()
|
||||||
|
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||||
|
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||||
|
const quiet = 4
|
||||||
|
size := n + quiet*2
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||||
|
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
// Dark modules are black; the red channel is 0 for those and full for the
|
||||||
|
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||||
|
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||||
|
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(`"/></svg>`)
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||||
// code again before enrolment is confirmed.
|
// code again before enrolment is confirmed.
|
||||||
func TOTPURI(secret, issuer, account string) string {
|
func TOTPURI(secret, issuer, account string) string {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
type totpSetupResponse struct {
|
type totpSetupResponse struct {
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
|
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
||||||
|
// hand on a device that cannot scan.
|
||||||
|
QR string `json:"qr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "store totp secret", err)
|
s.internal(w, r, "store totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
qrSVG, err := auth.TOTPQRSVG(uri)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "render totp qr", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpConfirmResponse struct {
|
type totpConfirmResponse struct {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
@@ -298,11 +298,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||||
|
|
||||||
// The service worker is what Web Push needs; without it a subscription cannot
|
// The service worker is what Web Push needs — and, since it also caches, what
|
||||||
// be created at all.
|
// carries the app through a short network drop. SvelteKit serves it under this
|
||||||
const worker = await request.get('/sw.js');
|
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||||
|
const worker = await request.get('/service-worker.js');
|
||||||
expect(worker.ok()).toBeTruthy();
|
expect(worker.ok()).toBeTruthy();
|
||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
// The source is minified, so the quotes around the event names are whatever the
|
||||||
|
// minifier chose — match either kind rather than one it might change under us.
|
||||||
|
const source = await worker.text();
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||||
|
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||||
|
* "you are offline" page.
|
||||||
|
*
|
||||||
|
* This is the whole point of the service worker's cache — not offline authoring,
|
||||||
|
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||||
|
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||||
|
* side of the same drop; this covers the read side.
|
||||||
|
*/
|
||||||
|
test.describe('med service worker', () => {
|
||||||
|
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||||
|
// test is about the worker, so it turns it back on for its own context.
|
||||||
|
test.use({ serviceWorkers: 'allow' });
|
||||||
|
|
||||||
|
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||||
|
// First load, online: the worker installs, and once it controls the page the
|
||||||
|
// shell and the reads it serves are in the cache.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait until the worker actually controls this page. Until it does, a reload
|
||||||
|
// still goes to the network and the test proves nothing — the first load
|
||||||
|
// registers the worker but the page that triggered the install is not itself
|
||||||
|
// controlled until it activates and claims.
|
||||||
|
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
// A controlled reload, still online: now the navigation and its API reads pass
|
||||||
|
// through the worker and land in the cache, ready for the drop.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// The network is gone.
|
||||||
|
await context.setOffline(true);
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
// The shell came from the cache — the sidebar is here, not a browser error
|
||||||
|
// page — and so did the task list the app renders into it.
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// Never leave the shared context offline for whatever test runs next.
|
||||||
|
await context.setOffline(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Off by default. The app now ships a caching service worker, and once it
|
||||||
|
// controls a page Playwright's page.route() no longer sees the fetches it
|
||||||
|
// makes — so a test that forces a 500 to check the error path gets the real
|
||||||
|
// 200 through the worker instead, and reads as a broken feature. The one test
|
||||||
|
// that needs the real worker turns it back on for its own context.
|
||||||
|
serviceWorkers: 'block',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
// Pinned, because the app reads both. The sign-up form sends
|
// Pinned, because the app reads both. The sign-up form sends
|
||||||
|
|||||||
@@ -210,27 +210,39 @@
|
|||||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kul — a neutral dark grey, the greys of Apple Notes rather than the default's
|
/* Kul — the greys of Apple Notes' dark mode, to Kristian's own sampled values.
|
||||||
faint green cast. The same green accent, so it still reads as verdande; only the
|
Three panel greys, lightest sidebar to darkest editor — the reverse of the usual
|
||||||
grounds, surfaces and inks turn to plain grey. */
|
sunken-is-darkest order, which is why the editor gets its own --editor-bg rather
|
||||||
|
than sharing --surface-raised with the list. The accent turns amber, and the
|
||||||
|
selected menu item, selected note and section headings each get a fallback token
|
||||||
|
so this theme can hit its colours without touching any other. */
|
||||||
[data-theme='charcoal'] {
|
[data-theme='charcoal'] {
|
||||||
--ground: #1c1c1c;
|
--ground: #1e1e1e;
|
||||||
--surface: #232323;
|
--surface: #2a3033;
|
||||||
--surface-raised: #2b2b2b;
|
--surface-raised: #242b2e; /* the note list */
|
||||||
--surface-sunken: #171717;
|
--surface-sunken: #2d3133; /* the menu / sidebar — lightest here */
|
||||||
--line: #363636;
|
--line: #34393b;
|
||||||
--line-strong: #474747;
|
--line-strong: #414749;
|
||||||
|
|
||||||
--ink: #ededed;
|
--ink: #ffffff;
|
||||||
--ink-muted: #a4a4a4;
|
--ink-muted: #dcdcdc;
|
||||||
/* Light enough to clear 4.5:1 against the ground — the quiet tier of dates and
|
--ink-faint: #9ba0a2;
|
||||||
counts is the first to disappear if it is too dim. */
|
|
||||||
--ink-faint: #8e8e8e;
|
|
||||||
|
|
||||||
--accent: #5fbf8f;
|
/* Amber, Apple's Notes accent. The bright value is the selected menu item; the
|
||||||
--accent-hover: #6fcf9f;
|
dark text rides on top of it. */
|
||||||
--accent-sunken: #23332b80;
|
--accent: #ffce35;
|
||||||
--accent-ink: #0d1411;
|
--accent-hover: #ffd75a;
|
||||||
|
--accent-sunken: #ffce35;
|
||||||
|
--accent-ink: #1e1e1e;
|
||||||
|
|
||||||
|
/* Per-element, and only here — every other theme falls back to the shared token
|
||||||
|
in the var() calls that read these. */
|
||||||
|
--editor-bg: #1e1e1e; /* the text field, a shade below the list */
|
||||||
|
--note-on-bg: #a78c26; /* a selected note in the list — gold */
|
||||||
|
--note-on-ink: #1e1e1e; /* dark text on the gold */
|
||||||
|
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
|
||||||
|
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
|
||||||
|
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
|
||||||
|
|
||||||
--p1: #cf6b6b;
|
--p1: #cf6b6b;
|
||||||
--p2: #d69f5c;
|
--p2: #d69f5c;
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
|
// Empty the offline data cache before leaving: the service worker keeps the
|
||||||
|
// last-seen task and note lists so a network drop does not blank the app, and
|
||||||
|
// the next person to sign in on this browser must not find them.
|
||||||
|
try {
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||||
|
await caches?.delete('verdande-api');
|
||||||
|
} catch {
|
||||||
|
// No service worker / no Cache API (older browser, private mode): nothing
|
||||||
|
// was cached, so nothing to clear.
|
||||||
|
}
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,7 +1346,9 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: var(--menu-size);
|
font-size: var(--menu-size);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink);
|
/* Kul sets these headings to a quiet grey (--heading-ink); every other theme
|
||||||
|
falls back to the reading ink and keeps them bold and bright. */
|
||||||
|
color: var(--heading-ink, var(--ink));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
/* The heading's mark, in the muted ink so the bold name still leads. Sized and
|
||||||
@@ -1591,7 +1603,10 @@
|
|||||||
in both themes. */
|
in both themes. */
|
||||||
a.active {
|
a.active {
|
||||||
background: var(--accent-sunken);
|
background: var(--accent-sunken);
|
||||||
color: var(--ink);
|
/* On a solid accent (Kul's amber menu item) the label goes dark via
|
||||||
|
--nav-active-ink; on the tinted accent-sunken every other theme uses, it
|
||||||
|
falls back to the reading ink. */
|
||||||
|
color: var(--nav-active-ink, var(--ink));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,13 +228,21 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--s3) var(--s2);
|
padding: var(--s3) var(--s2);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
transition:
|
transition:
|
||||||
opacity var(--medium) var(--ease),
|
opacity var(--medium) var(--ease),
|
||||||
transform var(--medium) var(--ease);
|
transform var(--medium) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
|
||||||
background: var(--surface);
|
target makes iOS spend the first tap applying the hover and only open on the
|
||||||
|
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
|
||||||
|
the task. The rounded corners are on the row itself, so the highlight is a
|
||||||
|
rounded box like a note in the list. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The exit: a short lift and fade, so completing something reads as the task
|
/* The exit: a short lift and fade, so completing something reads as the task
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Connectivity, as the app experiences it — not as the browser reports it.
|
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||||
*
|
*
|
||||||
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
|
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
|
||||||
* handing over. It is deliberately NOT offline support. The app is already loaded
|
* tunnel, a base station handing over. The READ side lives in the service worker,
|
||||||
* and its data is already in memory, so there is nothing to cache and nothing to
|
* which keeps the last-seen task and note lists so a reload through a drop is not a
|
||||||
* open from cold; what a blip needs is for the save you just made to survive it,
|
* blank screen. This store is the other half: it is what keeps the save you just made
|
||||||
* not for the whole app to run without a server.
|
* from being reverted under your hands while the network is briefly gone.
|
||||||
*
|
*
|
||||||
* The request layer drives this: it retries a failed request for a short window
|
* The request layer drives this: it retries a failed request for a short window
|
||||||
* (see api.js) and tells this store while it is trying, so the interface can say
|
* (see api.js) and tells this store while it is trying, so the interface can say
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ export function permission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registration() {
|
async function registration() {
|
||||||
// `ready` waits for an active worker; register() alone resolves while the
|
// SvelteKit registers src/service-worker.js itself on load, so normally there
|
||||||
// worker is still installing, and subscribing against that fails.
|
// is already a registration here. Register it by hand only if there is not yet
|
||||||
await navigator.serviceWorker.register('/sw.js');
|
// one — a push subscribe that races the app's own registration would otherwise
|
||||||
|
// find nothing. `ready` waits for an active worker; register() alone resolves
|
||||||
|
// while the worker is still installing, and subscribing against that fails.
|
||||||
|
const existing = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
|
||||||
return navigator.serviceWorker.ready;
|
return navigator.serviceWorker.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
|
|
||||||
let totpSecret = $state('');
|
let totpSecret = $state('');
|
||||||
let totpURI = $state('');
|
let totpURI = $state('');
|
||||||
|
let totpQR = $state('');
|
||||||
let totpCode = $state('');
|
let totpCode = $state('');
|
||||||
let totpErrors = $state({});
|
let totpErrors = $state({});
|
||||||
let recoveryCodes = $state([]);
|
let recoveryCodes = $state([]);
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
enablePassword = '';
|
enablePassword = '';
|
||||||
totpSecret = r.secret;
|
totpSecret = r.secret;
|
||||||
totpURI = r.uri;
|
totpURI = r.uri;
|
||||||
|
totpQR = r.qr;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totpErrors = e.fields ?? {};
|
totpErrors = e.fields ?? {};
|
||||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||||
@@ -177,6 +179,7 @@
|
|||||||
recoveryCodes = r.recovery_codes;
|
recoveryCodes = r.recovery_codes;
|
||||||
totpSecret = '';
|
totpSecret = '';
|
||||||
totpURI = '';
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
totpCode = '';
|
totpCode = '';
|
||||||
app.user = await api.me();
|
app.user = await api.me();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -492,8 +495,13 @@
|
|||||||
{:else if totpSecret}
|
{:else if totpSecret}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<p class="hint">{t('account.scanHint')}</p>
|
<p class="hint">{t('account.scanHint')}</p>
|
||||||
|
{#if totpQR}
|
||||||
|
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
|
||||||
|
white square, so it scans in either theme. It is the URI it already
|
||||||
|
sent, drawn; nothing here builds it. -->
|
||||||
|
<div class="qr">{@html totpQR}</div>
|
||||||
|
{/if}
|
||||||
<p class="mono secret">{totpSecret}</p>
|
<p class="mono secret">{totpSecret}</p>
|
||||||
<p class="hint mono uri">{totpURI}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={confirmTOTP}>
|
<form onsubmit={confirmTOTP}>
|
||||||
@@ -514,7 +522,14 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" type="submit">{t('account.confirm')}</button>
|
<button class="primary" type="submit">{t('account.confirm')}</button>
|
||||||
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
|
<button
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => {
|
||||||
|
totpSecret = '';
|
||||||
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
|
}}>{t('account.cancel')}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -859,6 +874,23 @@
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On its own white card whatever the theme: a QR is dark-on-light or it does not
|
||||||
|
scan, so the square keeps its own background rather than taking the page's. */
|
||||||
|
.qr {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
padding: var(--s2);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr :global(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.secret {
|
.secret {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3);
|
padding: var(--s3);
|
||||||
@@ -869,10 +901,6 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uri {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
||||||
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||||
.bmc {
|
.bmc {
|
||||||
|
|||||||
@@ -707,7 +707,10 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="notes">
|
<!-- `has-note` drives the phone layout: one column, showing the list until a note
|
||||||
|
is opened and the editor once one is. On a wide screen both are always up and
|
||||||
|
the class does nothing. -->
|
||||||
|
<div class="notes" class:has-note={selectedId}>
|
||||||
<aside>
|
<aside>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<h1>{t('notes.title')}</h1>
|
<h1>{t('notes.title')}</h1>
|
||||||
@@ -916,7 +919,15 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
{#if selected}
|
{#if selectedId}
|
||||||
|
<!-- Keyed on the id, which is set the instant a row is tapped — not on the
|
||||||
|
loaded note, which arrives a moment later. On a phone that moment was the
|
||||||
|
whole bug: the pane did not switch until the body had loaded, so the first
|
||||||
|
tap looked like nothing and people tapped again. -->
|
||||||
|
<!-- Phone only (hidden on a wide screen by CSS): the way back to the list,
|
||||||
|
since there the editor fills the screen and the list is not beside it. -->
|
||||||
|
<button class="back" onclick={() => open(null)}>‹ {t('notes.title')}</button>
|
||||||
|
{#if selected}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
@@ -1050,6 +1061,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{:else}
|
||||||
|
<!-- Chosen, but its full text is still loading. Keep the pane (so the tap
|
||||||
|
felt like it landed) and stay quiet — the "pick a note" line is for
|
||||||
|
when nothing is chosen at all, not for this half-second. -->
|
||||||
|
<p class="empty" aria-busy="true"></p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">{t('notes.pickOne')}</p>
|
<p class="empty">{t('notes.pickOne')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1389,7 +1406,10 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin: 0 0 var(--s1);
|
margin: 0 0 var(--s1);
|
||||||
background: var(--ground);
|
/* --ground everywhere but Kul, where the ground is the darkest thing on screen
|
||||||
|
and a day separator on it reads as a dark bar. There it takes the menu's own
|
||||||
|
resting colour instead, via --note-group-bg. */
|
||||||
|
background: var(--note-group-bg, var(--ground));
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,13 +1495,38 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
|
||||||
background: var(--surface);
|
spends the first applying the hover and opens on the second. Gated, the first
|
||||||
|
tap opens the note. The row's buttons are shown outright on touch below, since
|
||||||
|
there is no hover to reveal them. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.star,
|
||||||
|
.archive-one {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.on {
|
.row.on {
|
||||||
background: var(--surface-raised);
|
background: var(--note-on-bg, var(--surface-raised));
|
||||||
color: var(--ink);
|
color: var(--note-on-ink, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a themed selection colour (Kul's gold), the row's own quieter tiers go dark
|
||||||
|
too, or the date and the filed line vanish into it. Other themes have no
|
||||||
|
--note-on-ink, so these fall back to their normal muted inks. */
|
||||||
|
.row.on .when,
|
||||||
|
.row.on .filed {
|
||||||
|
color: var(--note-on-ink, var(--ink-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.on .filed-icon {
|
||||||
|
color: var(--note-on-ink, var(--ink-faint));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
/* Samme størrelse som en opgavetitel. En note og en opgave er to ting af samme
|
||||||
@@ -1577,8 +1622,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* A nuance lighter than the ground in dark, still white in light — matches
|
/* A nuance lighter than the ground in dark, still white in light — matches
|
||||||
the note list beside it so the two read as one writing surface. */
|
the note list beside it so the two read as one writing surface. Kul gives
|
||||||
background: var(--surface-raised);
|
the text field its own darker grey via --editor-bg; other themes fall back
|
||||||
|
to the shared surface so the list and the editor stay the same colour. */
|
||||||
|
background: var(--editor-bg, var(--surface-raised));
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
/* Luft nok til at teksten ikke rører kanten.
|
/* Luft nok til at teksten ikke rører kanten.
|
||||||
@@ -1897,14 +1944,48 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The way back to the list on a phone — an iOS-style "‹ Noter" at the top of the
|
||||||
|
editor. Hidden on a wide screen, where the list is always beside the editor. */
|
||||||
|
.back {
|
||||||
|
display: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
padding: var(--s1) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.notes {
|
.notes {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
aside {
|
/* One pane at a time. The two-column card layout has no room on a phone, so
|
||||||
border-right: 0;
|
the list shows until a note is opened and the editor takes over once one is
|
||||||
padding-right: 0;
|
— the back button is the way between them. Without this the editor stacked
|
||||||
|
below a full-height list, so a tapped note opened out of sight. */
|
||||||
|
.notes.has-note aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes:not(.has-note) .editor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed: the card border and rounded corners are for sitting beside
|
||||||
|
another card, and on one column there is nothing beside it. */
|
||||||
|
aside,
|
||||||
|
.editor {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/* global self, caches, fetch, Response */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* verdande's service worker: Web Push, and a cache that holds through a short
|
||||||
|
* network drop.
|
||||||
|
*
|
||||||
|
* It used to do Push and nothing else, with a note that a cache serving yesterday's
|
||||||
|
* data was worse than an honest "cannot connect". The cache here does not do that.
|
||||||
|
* It is network-FIRST for everything that can go stale — a reader online always gets
|
||||||
|
* the live answer, and the cache is only reached for when the network is not — so it
|
||||||
|
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
|
||||||
|
* lift, a base-station handover. The five themes' worth of live task list stays live;
|
||||||
|
* it just no longer goes blank the moment the signal does.
|
||||||
|
*
|
||||||
|
* Writes are never touched here — they go straight to the network, and api.js already
|
||||||
|
* retries them across a short drop. Caching a write would mean a queue and conflicts,
|
||||||
|
* which is a different and much larger feature.
|
||||||
|
*/
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
|
||||||
|
// old one is dropped on activate — the app can never be served half of two builds.
|
||||||
|
const SHELL = `verdande-shell-${version}`;
|
||||||
|
// Not versioned: the data does not belong to a build, and clearing it every deploy
|
||||||
|
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
|
||||||
|
const API = 'verdande-api';
|
||||||
|
|
||||||
|
// The shell: every hashed build asset, the static files, and the SPA fallback so a
|
||||||
|
// navigation can be answered from the cache when the network cannot answer it.
|
||||||
|
const SHELL_ASSETS = [...build, ...files, '/'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
await cache.addAll(SHELL_ASSETS);
|
||||||
|
// Take over at the next opportunity rather than waiting for every tab to
|
||||||
|
// close — paired with clients.claim below and the versioned cache, a deploy
|
||||||
|
// reaches the user on their next load.
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
// Drop old shell versions; keep this shell and the data cache.
|
||||||
|
if (key !== SHELL && key !== API) await caches.delete(key);
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Writes go to the network, full stop — the app retries them itself, and a cached
|
||||||
|
// write is a promise this cannot keep. Cross-origin is none of our business.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Hashed build assets and static files never change for a given URL: cache-first,
|
||||||
|
// so a repeat load and an offline load are both instant.
|
||||||
|
if (isAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirst(request, SHELL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data: network-first so an online reader always sees the live list, with the
|
||||||
|
// last good copy kept for when the network is gone.
|
||||||
|
if (url.pathname.startsWith('/api/v1/')) {
|
||||||
|
event.respondWith(networkFirst(request, API));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A navigation (reload, or opening the app): the live page when there is one, the
|
||||||
|
// cached shell when there is not — so the app opens through a drop instead of
|
||||||
|
// showing the browser's dinosaur.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(navigate(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
try {
|
||||||
|
const res = await fetch(request);
|
||||||
|
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
|
||||||
|
// last good copy, or an expired session would be served back offline as fact.
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(request) {
|
||||||
|
try {
|
||||||
|
return await fetch(request);
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
const shell = await cache.match('/');
|
||||||
|
return shell ?? Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-out asks for the data cache to be emptied: the next person on this browser must
|
||||||
|
// not find the last one's tasks sitting in it.
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'clear-api-cache') {
|
||||||
|
event.waitUntil(caches.delete(API));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
// A push whose body is not our JSON still deserves to be shown: browsers show
|
||||||
|
// their own "site updated in the background" notice otherwise, which looks like
|
||||||
|
// a bug in the app.
|
||||||
|
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = payload.title || 'verdande';
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: '/icon.svg',
|
||||||
|
badge: '/icon.svg',
|
||||||
|
// The tag collapses repeats: five comments on one task should replace each
|
||||||
|
// other rather than stack into five separate notifications.
|
||||||
|
tag: payload.tag || payload.url || 'verdande',
|
||||||
|
data: { url: payload.url || '/' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const target = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientList = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
includeUncontrolled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus a tab that already has the app open rather than opening a second one.
|
||||||
|
// Somebody with the app pinned does not want a new tab per reminder.
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (new URL(client.url).origin === self.location.origin) {
|
||||||
|
await client.focus();
|
||||||
|
if ('navigate' in client) await client.navigate(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(target);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* verdande's service worker.
|
|
||||||
*
|
|
||||||
* It exists for Web Push and nothing else. There is deliberately no offline cache:
|
|
||||||
* the app is a live view of a task list that other people are editing, and a cached
|
|
||||||
* shell serving yesterday's data is worse than a browser saying it cannot connect.
|
|
||||||
* Adding one later means versioning and invalidating it, which is a real feature
|
|
||||||
* rather than a few lines here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Take over as soon as the browser is willing. Without these two, the first push
|
|
||||||
// after granting permission arrives with no worker in control to show it.
|
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
|
||||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
let payload = {};
|
|
||||||
try {
|
|
||||||
payload = event.data ? event.data.json() : {};
|
|
||||||
} catch {
|
|
||||||
// A push whose body is not our JSON still deserves to be shown: browsers
|
|
||||||
// display their own "site updated in the background" notice otherwise, which
|
|
||||||
// looks like a bug in the app.
|
|
||||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = payload.title || 'verdande';
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(title, {
|
|
||||||
body: payload.body || '',
|
|
||||||
icon: '/icon.svg',
|
|
||||||
badge: '/icon.svg',
|
|
||||||
// The tag collapses repeats: five comments on one task should replace each
|
|
||||||
// other rather than stack into five separate notifications.
|
|
||||||
tag: payload.tag || payload.url || 'verdande',
|
|
||||||
data: { url: payload.url || '/' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const target = event.notification.data?.url || '/';
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const clientList = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus a tab that already has the app open rather than opening a second
|
|
||||||
// one. Somebody with the app pinned does not want a new tab per reminder.
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (new URL(client.url).origin === self.location.origin) {
|
|
||||||
await client.focus();
|
|
||||||
if ('navigate' in client) await client.navigate(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self.clients.openWindow(target);
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||