Merge pull request 'feat(web): SvelteKit PWA shell with login' (#3) from feat/web-shell into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.9",
|
||||||
|
"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",
|
||||||
|
"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
+5400
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