Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
dev-dist
.git
.github
*.tsbuildinfo
*.local
66 changes: 66 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Container image

# Publishes ghcr.io/libreble/<repo> (linux/amd64 + linux/arm64) with the built-in
# GITHUB_TOKEN — no registry account or secrets. Pull requests build and smoke-test only.
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true

jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

# Build for the runner, run it, and check the page, assets, SW, manifest and SPA fallback.
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: smoke:test
cache-from: type=gha
cache-to: type=gha,mode=max
- run: docker run -d --name smoke -p 8080:8080 smoke:test
- run: ./docker/smoke.sh http://localhost:8080/
- if: failure()
run: docker logs smoke

- if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
# Brushlog as a static site: build with Node, serve with unprivileged nginx on :8080.
# docker build -t brushlog . # served at /
# docker build --build-arg BASE_PATH=/brushlog/ -t brushlog . # served at /brushlog/

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG BASE_PATH=/
RUN BASE_PATH="$BASE_PATH" npm run build

FROM nginxinc/nginx-unprivileged:1.29-alpine
ARG BASE_PATH=/
ENV BASE_PATH=$BASE_PATH
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html${BASE_PATH}
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO /dev/null http://127.0.0.1:8080/healthz || exit 1
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,51 @@ npm run preview # serve the built app
npm run typecheck # tsc, no emit
```

## Self-host

The hosted app above is the easiest way. If you'd rather run your own copy, it's a static site —
nothing to configure, no backend, no database.

**Docker** — a prebuilt image (linux/amd64 + arm64) is published to the GitHub Container Registry:

```bash
docker run -d --name brushlog -p 8080:8080 --restart unless-stopped ghcr.io/libreble/brushlog
# → http://localhost:8080/
```

```yaml
# compose.yaml
services:
brushlog:
image: ghcr.io/libreble/brushlog:latest
ports: ["8080:8080"]
restart: unless-stopped
```

The image serves the app at `/`. To serve it under a subpath behind your own proxy, build it
yourself: `docker build --build-arg BASE_PATH=/brushlog/ -t brushlog .`

**Build and host it yourself** — any static web server works:

```bash
npm ci
BASE_PATH=/ npm run build # → dist/
# upload dist/ to nginx, Caddy, Netlify, Cloudflare Pages, a bucket, …
```

Set `BASE_PATH` to the path you serve from (it defaults to `/brushlog/`, the GitHub Pages path).
Two things your server should do: send unknown paths to `index.html` (client-side routes), and
serve `index.html` and `sw.js` with `Cache-Control: no-cache` so updates reach installed copies.
[`docker/nginx.conf.template`](docker/nginx.conf.template) is a working nginx example.

> **HTTPS is required.** Web Bluetooth only works in a secure context. `http://localhost` counts,
> so the app works on the machine running it — but `http://192.168.x.x:8080` from your phone
> will load and then refuse to connect. For phones, put it behind TLS: a reverse proxy with a
> real certificate (Caddy does this automatically for a domain), or `tailscale serve`.

Self-hosted copies keep their `<link rel="canonical">` pointing at libreble.github.io, so
search engines don't treat them as duplicates.

## Browser support

Web Bluetooth works in **Chrome/Edge on desktop and Android**. **iOS Safari is not supported**
Expand Down
36 changes: 36 additions & 0 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Static server for the built PWA. BASE_PATH is substituted at container start
# (nginx image envsubst; only defined env vars are replaced, so $uri stays as is).
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;

location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}

# Hashed build output: cache forever.
location ${BASE_PATH}assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options nosniff;
try_files $uri =404;
}

location ~ \.webmanifest$ {
default_type application/manifest+json;
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff;
}

# index.html, sw.js and everything else: always revalidate so updates land.
# Unknown paths get the app shell (client-side routes, deep links).
location ${BASE_PATH} {
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff;
try_files $uri $uri/ ${BASE_PATH}index.html;
}
}
23 changes: 23 additions & 0 deletions docker/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
# Smoke-test a running image: ./docker/smoke.sh http://localhost:8080/
set -eu
url="${1:-http://localhost:8080/}"
origin=$(printf '%s' "$url" | sed -E 's#(https?://[^/]+).*#\1#')
fail() { echo "FAIL: $*" >&2; exit 1; }

for i in $(seq 1 30); do curl -fs "$origin/healthz" >/dev/null && break; sleep 1; done
curl -fsS "$origin/healthz" | grep -q ok || fail healthz

html=$(curl -fsS "$url") || fail "index at $url"
echo "$html" | grep -q '<div id="root"' || fail "no #root in index"

# Every asset the page references must resolve.
for a in $(echo "$html" | grep -oE '(src|href)="[^"]+\.(js|css|svg|png|webmanifest)"' | sed -E 's/^[a-z]+="//; s/"$//' | grep -v '^http'); do
case "$a" in /*) u="$origin$a" ;; *) u="$url$a" ;; esac
curl -fsS -o /dev/null "$u" || fail "asset $u"
done

curl -fsS "${url}sw.js" | grep -q . || fail sw.js
curl -fsSI "${url}manifest.webmanifest" | grep -qi 'content-type: application/manifest+json' || fail "manifest content-type"
curl -fsS "${url}some/deep/link" | grep -q '<div id="root"' || fail "SPA fallback"
echo "smoke OK: $url"
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@
</head>
<body class="h-full">
<div id="root" class="h-full"></div>
<noscript>
<h1>Brushlog — Oral-B toothbrush history &amp; live coaching</h1>
<p>
Sync the brushing history your Oral-B iO already stores, see live pressure, brush-head
wear, streaks and trends — over Web Bluetooth, with no Oral-B account and no cloud. It
needs JavaScript and Chrome on Android or Chrome/Edge on desktop.
</p>
<p><a href="https://github.com/libreble/brushlog">Source and protocol documentation</a></p>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.13.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/web-bluetooth": "^0.0.21",
Expand Down
1 change: 1 addition & 0 deletions tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"types": ["node"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
Expand Down
14 changes: 9 additions & 5 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { VitePWA } from 'vite-plugin-pwa';

// Brushlog is a fully local PWA: no backend, no analytics, no cloud.
// The service worker precaches the app shell so it runs offline once installed.

// Served from the /brushlog/ subpath on GitHub Pages (https://libreble.github.io/brushlog/).
// Self-hosters override it: `BASE_PATH=/ npm run build` (the Docker image does this).
const base = `/${(process.env.BASE_PATH ?? '/brushlog/').replace(/^\/+|\/+$/g, '')}/`.replace('//', '/');

export default defineConfig({
// Served from the /brushlog/ subpath on GitHub Pages (https://libreble.github.io/brushlog/).
base: '/brushlog/',
base,
plugins: [
react(),
tailwindcss(),
Expand All @@ -30,9 +34,9 @@ export default defineConfig({
background_color: '#0b1120',
display: 'standalone',
orientation: 'portrait',
id: '/brushlog/',
start_url: '/brushlog/',
scope: '/brushlog/',
id: base,
start_url: base,
scope: base,
// PNG icons (raster) for launchers that don't render SVG app icons; SVG kept as scalable
// `any`. Generated from the SVGs (see public/icon*.png). Maskable variants full-bleed the
// teal background so Android's adaptive-icon safe-zone crops cleanly.
Expand Down
Loading