feat(web): SvelteKit PWA shell with login #3
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.git
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,6 @@
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
static/icons/
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": { "parser": "svelte" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# This image's ONLY purpose is to produce /app/build — the static SPA output
|
||||
# (HTML/CSS/JS/manifest/service worker). There is no Node runtime in
|
||||
# production: per docs/PLAN.md's "Stack" and "The PWA decision", Caddy serves
|
||||
# these files directly and proxies /api/* to the FastAPI backend. This image
|
||||
# is never run as a long-lived container in production.
|
||||
#
|
||||
# Build context: this directory (apps/web), e.g. `docker build -f
|
||||
# apps/web/Dockerfile apps/web`.
|
||||
#
|
||||
# How deploy/ is expected to consume this: build this image, then copy its
|
||||
# /app/build contents out into a location the `caddy` service in
|
||||
# deploy/docker-compose.yml bind-mounts — either via a multi-stage
|
||||
# `COPY --from=bennybergle/velodrome-web:<tag> /app/build /srv/web` in the
|
||||
# Caddy image build, or with a one-shot `docker create` + `docker cp` /
|
||||
# `docker run --rm -v ...` step in the deploy pipeline that dumps /app/build
|
||||
# into a named volume or bind-mounted host directory before `caddy` starts.
|
||||
# Picked this over a scratch/busybox "artifact-holder" final stage because a
|
||||
# single builder stage is simpler to reason about and there's nothing here
|
||||
# that needs to run — the consumer only ever needs the files, not a container.
|
||||
# The deploy/ agent may adjust this to whatever's simplest for the compose
|
||||
# setup; this is just the contract (build → /app/build).
|
||||
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first, isolated from source changes, for layer caching.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate \
|
||||
&& pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
# Nothing further: /app/build is the artifact. No CMD/ENTRYPOINT — this image
|
||||
# is not meant to be run.
|
||||
+99
-1
@@ -1 +1,99 @@
|
||||
SvelteKit static SPA, installable PWA. Not yet scaffolded — Phase 0.
|
||||
# apps/web — Velodrome PWA shell
|
||||
|
||||
SvelteKit static SPA, installable PWA. Talks to the API at `/api/v1` — same-origin in production
|
||||
(Caddy proxies it), same-origin locally via the dev proxy in `vite.config.ts`. See
|
||||
`docs/PLAN.md` ("Stack", "The PWA decision") for why this shape was chosen; this file is just the
|
||||
how.
|
||||
|
||||
## Dev commands
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm run dev # vite dev server, proxies /api -> http://localhost:8000
|
||||
pnpm run check # svelte-kit sync + svelte-check
|
||||
pnpm run lint # prettier --check + eslint
|
||||
pnpm run format # prettier --write
|
||||
pnpm run build # production build -> build/
|
||||
pnpm run preview # serve the production build locally
|
||||
```
|
||||
|
||||
`pnpm run lint`, `pnpm run check`, and `pnpm run build` are exactly the three steps CI's `web` job
|
||||
runs (`.gitea/workflows/ci.yml`) — keep those script names stable.
|
||||
|
||||
Requires Node 22 and pnpm (via corepack). The service worker is disabled in dev
|
||||
(`devOptions.enabled: false` in `vite.config.ts`) — dev already has instant HMR, and a dev-mode SW
|
||||
mostly just causes "why isn't my change showing up" confusion.
|
||||
|
||||
## Shape of the app
|
||||
|
||||
- **adapter-static, SPA mode, no prerendering.** `src/routes/+layout.ts` sets `export const ssr =
|
||||
false`, and `vite.config.ts` configures `@sveltejs/adapter-static` with `fallback: 'index.html'`.
|
||||
This is a client-only app: there's no Node process in production (see "Dockerfile" below), so
|
||||
every route has to resolve client-side against a single served shell, not be prerendered.
|
||||
- **Auth.** `src/lib/stores/auth.ts` is a tiny Svelte store backed by `GET /api/v1/auth/me`.
|
||||
`src/routes/+layout.svelte` calls `auth.refresh()` once on load; `/` and `/login` react to the
|
||||
store to decide what to show/redirect to. Login posts to `/api/v1/auth/login`
|
||||
(`src/lib/api/auth.ts`) and relies on the API setting an HttpOnly cookie — the app never touches
|
||||
the token directly.
|
||||
- **PWA / service worker.** `@vite-pwa/sveltekit` is _not_ used here — see the long comment block at
|
||||
the top of `vite.config.ts` for why (its `injectManifest` build expects SvelteKit's own built-in
|
||||
`src/service-worker.ts` convention to have already transpiled the file, but that native build only
|
||||
permits importing SvelteKit's own three virtual modules and hard-rejects `workbox-*` imports). We
|
||||
use the base `vite-plugin-pwa` (`VitePWA(...)`) instead, in `injectManifest` mode, and explicitly
|
||||
disable SvelteKit's native service-worker convention (`kit.files.serviceWorker` pointed at a
|
||||
nonexistent path) so only vite-plugin-pwa's build runs.
|
||||
|
||||
`injectManifest`, not `generateSW`: the caching policy is hand-written in `src/service-worker.ts`
|
||||
using Workbox primitives directly, because the policy needs to say "never, ever cache `/api/*`" —
|
||||
more precisely than `generateSW`'s declarative config expresses. The policy, straight from
|
||||
`docs/PLAN.md`'s caching table:
|
||||
- Precache the injected build manifest (hashed JS/CSS/icons/`manifest.webmanifest`), plus a
|
||||
synthetic entry for the SPA fallback `index.html` (see the `swIndexRevision` comment in
|
||||
`vite.config.ts` — the real `build/index.html` doesn't exist yet when the SW is built, since
|
||||
the adapter writes it afterward, so its precache revision is a per-build-invocation timestamp
|
||||
instead of a content hash).
|
||||
- Navigations: cache-first, served from the precached `index.html` via
|
||||
`NavigationRoute(createHandlerBoundToURL('index.html'))` — the app opens instantly and works
|
||||
offline.
|
||||
- `/api/*`: `NetworkOnly`, unconditionally. Never cached, per CLAUDE.md's invariants.
|
||||
- Nothing else yet — tile/stream caching is a later phase; the file is structured so those are
|
||||
additional `registerRoute()` calls, not a rewrite.
|
||||
|
||||
`skipWaiting()` + `clientsClaim()` run immediately. `src/lib/components/UpdateToast.svelte` uses
|
||||
`virtual:pwa-register/svelte`'s `useRegisterSW()` to show a "New version available — reload"
|
||||
toast instead of silently swapping the SW under the user.
|
||||
|
||||
- **Manifest / icons.** `static/manifest.webmanifest` is hand-written (not plugin-generated) and
|
||||
linked explicitly from `src/app.html`, along with the `apple-touch-icon`,
|
||||
`apple-mobile-web-app-capable` meta, and `viewport-fit=cover` (paired with
|
||||
`env(safe-area-inset-*)` padding in the root layout) for iOS standalone mode. Icons in
|
||||
`static/icons/` are placeholder solid-color PNGs — fine for Phase 0, swap them for real artwork
|
||||
later.
|
||||
- **iOS install banner.** `src/lib/components/IosInstallBanner.svelte` — informational only
|
||||
("Add to Home Screen" instructions), shown when not already standalone and the UA looks like iOS
|
||||
Safari. Dismissible per session (`sessionStorage`), reappears next session until actually
|
||||
installed. No `Notification.requestPermission()` here — that's gated behind standalone mode in a
|
||||
later phase, per `docs/PLAN.md`'s PWA-decision table.
|
||||
- **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme`
|
||||
for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
This image's **only** purpose is to produce `/app/build` (the static SPA output) as a buildable
|
||||
artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no
|
||||
Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API
|
||||
container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container.
|
||||
|
||||
Build it with the `apps/web` directory as context:
|
||||
|
||||
```sh
|
||||
docker build -f apps/web/Dockerfile -t velodrome-web-build apps/web
|
||||
```
|
||||
|
||||
How `deploy/` is expected to consume it (my assumption — the `deploy/` work is happening in a
|
||||
parallel worktree, so this may get adjusted there): a multi-stage `COPY --from=velodrome-web-build
|
||||
/app/build /srv/web` in whatever image serves Caddy, or a one-shot `docker create` +
|
||||
`docker cp` / bind-mount step in the deploy pipeline that populates the volume Caddy reads from
|
||||
before it starts. Went with a single plain builder stage (no `scratch`/`busybox` artifact-holder
|
||||
final stage) because nothing here needs to _run_ — the only thing anyone needs from this image is
|
||||
the files in `/app/build`, and a second stage would add complexity without adding anything.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
// No svelte.config.js in this project — SvelteKit's config (adapter, etc.)
|
||||
// lives inline in vite.config.ts's sveltekit() plugin call, and there are no
|
||||
// non-default Svelte preprocessors, so eslint-plugin-svelte needs nothing
|
||||
// from it.
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// The service worker runs in a webworker global scope, not a browser
|
||||
// window — see src/service-worker.ts's own triple-slash lib directives.
|
||||
files: ['src/service-worker.ts'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.serviceworker }
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/', 'node_modules/']
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.12.0",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
Generated
+5430
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
:root {
|
||||
--color-bg: #f5f6f8;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-text: #14161a;
|
||||
--color-text-muted: #5b6169;
|
||||
--color-border: #dfe2e7;
|
||||
--color-accent: #2f6fed;
|
||||
--color-accent-contrast: #ffffff;
|
||||
--color-danger: #d1453b;
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0f1115;
|
||||
--color-surface: #16191f;
|
||||
--color-surface-raised: #1e222a;
|
||||
--color-text: #e8e8ec;
|
||||
--color-text-muted: #9198a3;
|
||||
--color-border: #2a2f38;
|
||||
--color-accent: #5b8dfd;
|
||||
--color-accent-contrast: #0f1115;
|
||||
--color-danger: #ff6b60;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
|
||||
// Ambient type for the `virtual:pwa-register/svelte` module used by
|
||||
// src/lib/components/UpdateToast.svelte (see vite-plugin-pwa/svelte.d.ts).
|
||||
/// <reference types="vite-plugin-pwa/svelte" />
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png" />
|
||||
|
||||
<!-- iOS ignores rel=manifest for install chrome; these are the standalone-mode hooks. -->
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Velodrome" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
export type LoginResult = { ok: true } | { ok: false; reason: 'invalid-credentials' | 'network' };
|
||||
|
||||
/**
|
||||
* POSTs credentials to /api/v1/auth/login. On success the API sets an HttpOnly
|
||||
* session cookie in the response — we never see or handle the token itself, we
|
||||
* just let the browser store it (same-origin, so this works through Caddy in
|
||||
* production and through the dev proxy in vite.config.ts locally).
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<LoginResult> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (res.status === 401) {
|
||||
return { ok: false, reason: 'invalid-credentials' };
|
||||
}
|
||||
return { ok: false, reason: 'network' };
|
||||
} catch {
|
||||
return { ok: false, reason: 'network' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
// Phase 0: purely informational. No Notification.requestPermission() call
|
||||
// anywhere here — that's gated behind standalone mode in a later phase, per
|
||||
// docs/PLAN.md's PWA-decision table ("request only from a direct user
|
||||
// gesture, only in standalone, only after an explanatory screen").
|
||||
const DISMISS_KEY = 'ios-install-banner-dismissed';
|
||||
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia('(display-mode: standalone)').matches;
|
||||
}
|
||||
|
||||
function isIosSafari(): boolean {
|
||||
const ua = window.navigator.userAgent;
|
||||
const isIos =
|
||||
/iPad|iPhone|iPod/.test(ua) || (ua.includes('Macintosh') && navigator.maxTouchPoints > 1);
|
||||
// Exclude in-app browsers / other iOS browser shells that spoof Safari's
|
||||
// UA but can't install (CriOS, FxiOS, and generic wrappers with "wv").
|
||||
const isOtherBrowser = /CriOS|FxiOS|EdgiOS|OPiOS|mercury|wv\)/.test(ua);
|
||||
return isIos && !isOtherBrowser;
|
||||
}
|
||||
|
||||
// Reappears each session (sessionStorage, not localStorage) until the user
|
||||
// actually installs, per the task: dismissible but not permanently.
|
||||
let dismissed = $state(false);
|
||||
let show = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
try {
|
||||
dismissed = sessionStorage.getItem(DISMISS_KEY) === '1';
|
||||
} catch {
|
||||
dismissed = false;
|
||||
}
|
||||
show = !isStandalone() && isIosSafari();
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
try {
|
||||
sessionStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch {
|
||||
// Private browsing or storage disabled — banner just won't remember
|
||||
// the dismissal for this session, which is a harmless fallback.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show && !dismissed}
|
||||
<div class="ios-banner" role="note">
|
||||
<button class="close" type="button" aria-label="Dismiss" onclick={dismiss}>×</button>
|
||||
<p><strong>Install Velodrome</strong> for offline access and reminders:</p>
|
||||
<ol>
|
||||
<li>Tap the Share icon in Safari's toolbar.</li>
|
||||
<li>Scroll down and tap "Add to Home Screen".</li>
|
||||
<li>Tap "Add" — Velodrome now opens full-screen, like a native app.</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ios-banner {
|
||||
position: relative;
|
||||
margin: 0.75rem;
|
||||
padding: 0.85rem 2.25rem 0.85rem 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { useRegisterSW } from 'virtual:pwa-register/svelte';
|
||||
|
||||
// needRefresh flips true once a new service worker is waiting; calling
|
||||
// updateServiceWorker() sends skipWaiting and reloads once the new SW takes
|
||||
// control (see src/service-worker.ts's `skipWaiting()` / `clientsClaim()`).
|
||||
const { needRefresh, updateServiceWorker } = useRegisterSW({
|
||||
onRegisterError(error) {
|
||||
// Non-fatal: the app works without a service worker, just without
|
||||
// offline/instant-open. Don't surface this to the user.
|
||||
console.error('service worker registration failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
let reloading = $state(false);
|
||||
|
||||
async function reload() {
|
||||
reloading = true;
|
||||
await updateServiceWorker(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $needRefresh}
|
||||
<div class="update-toast" role="status">
|
||||
<span>New version available.</span>
|
||||
<button type="button" onclick={reload} disabled={reloading}>
|
||||
{reloading ? 'Reloading…' : 'Reload'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.update-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 0.25);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'unknown' }
|
||||
| { status: 'authenticated'; user: AuthUser }
|
||||
| { status: 'unauthenticated' };
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set } = writable<AuthState>({ status: 'unknown' });
|
||||
|
||||
/** Calls GET /api/v1/auth/me and updates the store. Never throws. */
|
||||
async function refresh(): Promise<AuthState> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const user = (await res.json()) as AuthUser;
|
||||
const next: AuthState = { status: 'authenticated', user };
|
||||
set(next);
|
||||
return next;
|
||||
}
|
||||
} catch {
|
||||
// Network failure: fall through to unauthenticated. The login page's own
|
||||
// fetch will surface a retry message if the user tries to act.
|
||||
}
|
||||
const next: AuthState = { status: 'unauthenticated' };
|
||||
set(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
} finally {
|
||||
set({ status: 'unauthenticated' });
|
||||
}
|
||||
}
|
||||
|
||||
return { subscribe, refresh, logout };
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||
import IosInstallBanner from '$lib/components/IosInstallBanner.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// One auth check per app load; routes react to the store rather than each
|
||||
// re-fetching /api/v1/auth/me themselves.
|
||||
void auth.refresh();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="app-shell">
|
||||
<IosInstallBanner />
|
||||
{@render children()}
|
||||
<UpdateToast />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Static SPA: nothing here is ever server-rendered or prerendered. Caddy serves the
|
||||
// build output as files and falls back to index.html for unknown paths (see
|
||||
// vite.config.ts's adapter-static config, `fallback: 'index.html'`).
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
$effect(() => {
|
||||
if ($auth.status === 'unauthenticated') {
|
||||
goto(resolve('/login'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $auth.status === 'authenticated'}
|
||||
<main class="dashboard">
|
||||
<div class="header-row">
|
||||
<h1>Velodrome</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="logout"
|
||||
onclick={async () => {
|
||||
await auth.logout();
|
||||
goto(resolve('/login'));
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<p class="signed-in-as">Signed in as {$auth.user.email}</p>
|
||||
<p class="empty-state">No rides yet.</p>
|
||||
</main>
|
||||
{:else if $auth.status === 'unknown'}
|
||||
<main class="dashboard" aria-busy="true">
|
||||
<p>Loading…</p>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.logout {
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signed-in-as {
|
||||
margin: 0 0 2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { login } from '$lib/api/auth';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
// Already signed in? Don't show the form.
|
||||
$effect(() => {
|
||||
if ($auth.status === 'authenticated') {
|
||||
goto(resolve('/'));
|
||||
}
|
||||
});
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let submitting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
error = null;
|
||||
|
||||
const result = await login(email, password);
|
||||
if (result.ok) {
|
||||
await auth.refresh();
|
||||
await goto(resolve('/'));
|
||||
return;
|
||||
}
|
||||
|
||||
error =
|
||||
result.reason === 'invalid-credentials'
|
||||
? 'Invalid email or password.'
|
||||
: 'Could not reach the server. Check your connection and try again.';
|
||||
submitting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="login">
|
||||
<h1>Velodrome</h1>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="username" required bind:value={email} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.login {
|
||||
max-width: 22rem;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
/// <reference lib="webworker" />
|
||||
// Hand-written service worker (injectManifest strategy, not generateSW) — see
|
||||
// vite.config.ts for why. Keep this file's job narrow: caching and (later)
|
||||
// displaying pushes. Nothing here should be load-bearing for the app to work,
|
||||
// per docs/PLAN.md's "Aggressive service-worker termination" row.
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import {
|
||||
cleanupOutdatedCaches,
|
||||
createHandlerBoundToURL,
|
||||
precacheAndRoute
|
||||
} from 'workbox-precaching';
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||
import { NetworkOnly } from 'workbox-strategies';
|
||||
import type { PrecacheEntry } from 'workbox-precaching';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<PrecacheEntry | string>;
|
||||
};
|
||||
|
||||
// Activate the new SW as soon as it's done installing, and take control of
|
||||
// open tabs immediately, rather than waiting for a reload. Paired with the
|
||||
// in-app "New version available" prompt (src/lib/components/UpdateToast.svelte)
|
||||
// so this never surprises the user mid-session.
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
// Precache the injected manifest: hashed build assets plus the SPA fallback
|
||||
// index.html (kit.spa / kit.adapterFallback in vite.config.ts adds it here).
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// --- Navigations: cache-first app shell ------------------------------------
|
||||
// Every navigation (including deep links SvelteKit's client router will take
|
||||
// over from) resolves from the precached index.html, so the app opens
|
||||
// instantly and offline. This is the app-shell pattern generateSW's
|
||||
// `navigateFallback` gives you for free; we do it by hand here because
|
||||
// everything else in this file needs injectManifest.
|
||||
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html')));
|
||||
|
||||
// --- API: network-only, always ----------------------------------------------
|
||||
// Never cache anything under /api/ in this phase. An API response cached by
|
||||
// the service worker is a correctness bug here, not a UX nicety — see
|
||||
// CLAUDE.md's invariants and docs/PLAN.md's caching table ("network-only on
|
||||
// all mutations, never silently cached"). NetworkOnly also means a request
|
||||
// that fails offline fails loudly instead of silently returning stale data.
|
||||
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
|
||||
|
||||
// --- Future runtime caches ---------------------------------------------------
|
||||
// Deliberately empty for Phase 0. Later phases add, e.g.:
|
||||
// registerRoute(({url}) => url.pathname.startsWith('/tiles/'), new CacheFirst({...}))
|
||||
// registerRoute(({url}) => url.pathname === '/garage', new NetworkFirst({ networkTimeoutSeconds: 3, ... }))
|
||||
// Keep those as separate registerRoute() calls below this comment, one route
|
||||
// per surface, so the caching policy stays legible route-by-route.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 902 B |
Binary file not shown.
|
After Width: | Height: | Size: 965 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Velodrome",
|
||||
"short_name": "Velodrome",
|
||||
"description": "Self-hosted cycling ride sync, mileage tracking, and maintenance reminders.",
|
||||
"start_url": "/?src=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1115",
|
||||
"theme_color": "#0f1115",
|
||||
"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-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// The adapter-static SPA fallback (build/index.html) is written by the adapter
|
||||
// *after* every Vite plugin's build has finished, so at service-worker-build
|
||||
// time there's no file for injectManifest to glob and hash. We instead give it
|
||||
// a synthetic precache entry (below) with a revision that's fresh per build
|
||||
// invocation, so the shell still gets correctly invalidated on every deploy.
|
||||
const swIndexRevision = String(Date.now());
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
// Local dev only. In production Caddy does this same-origin proxy
|
||||
// (see deploy/Caddyfile) — the app only ever calls same-origin /api/v1.
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) =>
|
||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
|
||||
// SvelteKit has its own built-in convention for src/service-worker.{js,ts}:
|
||||
// it auto-detects the file by this exact path and runs its own separate,
|
||||
// very restricted build for it (only $service-worker, $env/static/public and
|
||||
// $app/env/public may be imported — nothing from npm). vite-plugin-pwa (below)
|
||||
// bundles and injects our actual service worker instead; if both run, the
|
||||
// SvelteKit's native pass executes *after* vite-plugin-pwa's and silently
|
||||
// overwrites its output with an un-bundled, un-injected file (the
|
||||
// self.__WB_MANIFEST placeholder is left as literal text — the app "works"
|
||||
// but nothing is ever precached). Point SvelteKit's lookup at a path that
|
||||
// doesn't exist so only vite-plugin-pwa's build runs.
|
||||
files: {
|
||||
serviceWorker: 'src/service-worker-disabled'
|
||||
},
|
||||
|
||||
// Static SPA: no Node process in production. Caddy serves the build output
|
||||
// directly and falls back to index.html for client-side routes (see
|
||||
// docs/PLAN.md "Stack" and "The PWA decision"). Pair with `export const ssr =
|
||||
// false` in src/routes/+layout.ts — without that, SvelteKit still tries to
|
||||
// prerender/SSR routes and the static adapter fails the build.
|
||||
adapter: adapter({
|
||||
fallback: 'index.html'
|
||||
})
|
||||
}),
|
||||
VitePWA({
|
||||
// injectManifest, not generateSW: we hand-write the caching policy (cache-first
|
||||
// navigations, network-only for /api/*) in src/service-worker.ts. generateSW's
|
||||
// declarative config can't express "never cache this origin path" as precisely
|
||||
// as we want here — see docs/PLAN.md's caching table.
|
||||
//
|
||||
// We use the base vite-plugin-pwa plugin rather than @vite-pwa/sveltekit's
|
||||
// SvelteKit-specific wrapper: that wrapper's injectManifest build expects
|
||||
// SvelteKit's own built-in src/service-worker.{js,ts} convention to have
|
||||
// already transpiled our file, which only resolves SvelteKit's own three
|
||||
// virtual modules and rejects anything else. The base plugin bundles
|
||||
// src/service-worker.ts itself directly, which is what we want here.
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'service-worker.ts',
|
||||
// We own the manifest as a plain static file (static/manifest.webmanifest),
|
||||
// linked explicitly from app.html, instead of letting the plugin generate one.
|
||||
manifest: false,
|
||||
// We register the service worker ourselves via virtual:pwa-register/svelte
|
||||
// (see src/lib/components/UpdateToast.svelte) so we can show an in-app update
|
||||
// prompt instead of the plugin's default injected register script.
|
||||
injectRegister: false,
|
||||
injectManifest: {
|
||||
injectionPoint: 'self.__WB_MANIFEST',
|
||||
// Default globPatterns is just JS/CSS/HTML — widen it to also precache
|
||||
// our icons and manifest so an offline install has everything it needs.
|
||||
globPatterns: ['**/*.{js,css,svg,png,webmanifest}'],
|
||||
// See the swIndexRevision comment above: the real build/index.html
|
||||
// doesn't exist yet when this runs, so we can't glob-hash it.
|
||||
additionalManifestEntries: [{ url: 'index.html', revision: swIndexRevision }]
|
||||
},
|
||||
devOptions: {
|
||||
// Keep the SW out of `pnpm dev` — dev already has instant HMR, and a dev-mode
|
||||
// SW is a common source of "why is my change not showing up" confusion.
|
||||
enabled: false,
|
||||
type: 'module'
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user