Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a transfer tarball. This is the first commit: the Express server and its web front end, the Capacitor iOS and Android wrappers, and the release and backup scripts. The repo is the source, not a backup of the machine. Left out on purpose, by the .gitignore files that were already in the tree: node_modules, build output, mobile/www (regenerated from server/public on every build), and server/data, which holds the live checklists and belongs on the NAS rather than in git. Every credential is excluded too — server/.env, the Play upload keystore and its properties file, the App Store Connect key and ios-signing.config. The one secret that was not in an ignored file was the NAS SSH password, hard-coded in build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read it from the login keychain (service checklist-nas, account Christian), falling back to NAS_PASS in the environment, so rotating it is one keychain entry rather than a history rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -0,0 +1,41 @@
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Recurring Checklist — server configuration
|
||||
# Copy this file to .env and fill in your values.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port the web server listens on (inside the container).
|
||||
PORT=3000
|
||||
|
||||
# Password required to manage lists (add / rename / delete / import / export).
|
||||
# Anyone without this password can only check items off.
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# ─── Email digest ────────────────────────────────────────────────
|
||||
# REPORT_EMAIL gets the GLOBAL digest (every location/team in one email) — keep
|
||||
# it set for an at-a-glance master copy. PER-TEAM digests are configured in-app
|
||||
# under Manage Users: each team login can have its own report recipient(s), and
|
||||
# those go out on the same schedule below, one email per team. Leave REPORT_EMAIL
|
||||
# blank to send only the per-team emails.
|
||||
REPORT_EMAIL=you@example.com
|
||||
|
||||
# Cron schedule for the digest. Default: every Sunday at 3:00 PM.
|
||||
# Format: minute hour day-of-month month day-of-week
|
||||
REPORT_CRON=0 15 * * 0
|
||||
|
||||
# IANA timezone the cron runs in.
|
||||
REPORT_TZ=America/New_York
|
||||
|
||||
# After the digest is sent, automatically uncheck all items so the
|
||||
# list is fresh for next week. Set to "false" to disable.
|
||||
RESET_AFTER_DIGEST=true
|
||||
|
||||
# ─── SMTP (Gmail app-password example) ───────────────────────────
|
||||
# For Gmail: enable 2-factor auth on your Google account, then create
|
||||
# an "App Password" at https://myaccount.google.com/apppasswords and
|
||||
# paste it into SMTP_PASS below.
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your-16-char-app-password
|
||||
SMTP_FROM=Recurring Checklist <you@gmail.com>
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
data/*.json
|
||||
*.log
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first for better caching
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
|
||||
|
||||
# Copy application code
|
||||
COPY server.js ./
|
||||
COPY public ./public
|
||||
|
||||
# Persisted data lives here; mount a volume in compose
|
||||
RUN mkdir -p /app/data
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/healthz >/dev/null 2>&1 || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,161 @@
|
||||
# Recurring Checklist — Self-hosted Web Version
|
||||
|
||||
A small Node.js web app that anyone on your network can hit from a browser.
|
||||
Designed to run on a **Synology NAS** via Docker (Container Manager). Every
|
||||
Sunday at 3:00 PM it emails you a digest of who checked off what — and what
|
||||
got missed — then resets the lists for the next week.
|
||||
|
||||
```
|
||||
Users → http://nas-ip:3000 → tick boxes → server stores state
|
||||
↓
|
||||
Sun 3:00 PM cron
|
||||
↓
|
||||
Gmail SMTP → digest email → you
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's where
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `server.js` | Express server, REST API, scheduler, email |
|
||||
| `public/` | Static frontend (HTML/CSS/JS) served by Express |
|
||||
| `data/` | Persisted state (`checklists.json`) — **must be a volume mount** |
|
||||
| `.env.example` | Copy to `.env` and fill in |
|
||||
| `Dockerfile` | Builds the container image |
|
||||
| `docker-compose.yml` | One-command deploy |
|
||||
|
||||
---
|
||||
|
||||
## 1. Set up a Gmail App Password (for the digest email)
|
||||
|
||||
1. Sign in to the Google account you want to send from (your own, or a
|
||||
dedicated one — both work).
|
||||
2. Turn on 2-Step Verification at <https://myaccount.google.com/security>.
|
||||
3. Once 2-Step is on, go to <https://myaccount.google.com/apppasswords>.
|
||||
4. Create an app password called "Recurring Checklist". Google gives you a
|
||||
16-character string like `abcd efgh ijkl mnop`. Save it.
|
||||
5. You'll paste that into `SMTP_PASS` in `.env` (with or without spaces, both work).
|
||||
|
||||
> Don't want to use Gmail? Any SMTP works. Office 365, Fastmail, your own
|
||||
> Postfix on the Synology — just swap the `SMTP_*` vars in `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploy on Synology with Container Manager
|
||||
|
||||
These steps assume **DSM 7.2+** with Container Manager installed.
|
||||
|
||||
### A. Copy the `server/` folder to the NAS
|
||||
|
||||
Drop this whole folder into something like
|
||||
`/volume1/docker/recurring-checklist/` using File Station, SMB, rsync —
|
||||
whatever's easiest. The folder must end up with `Dockerfile`,
|
||||
`docker-compose.yml`, `server.js`, `public/`, etc.
|
||||
|
||||
### B. Create the `.env` file on the NAS
|
||||
|
||||
In that folder, copy `.env.example` to `.env` and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in:
|
||||
|
||||
- `ADMIN_PASSWORD` — a password you'll use to manage lists in the browser.
|
||||
- `REPORT_EMAIL` — your inbox.
|
||||
- `SMTP_USER` — the Gmail address you set up in step 1.
|
||||
- `SMTP_PASS` — the 16-character app password.
|
||||
- `SMTP_FROM` — `Recurring Checklist <yourgmail@gmail.com>` is fine.
|
||||
- `REPORT_TZ` — your local timezone (default `America/New_York`).
|
||||
- `REPORT_CRON` — `0 15 * * 0` = Sunday at 3:00 PM. Leave alone unless you
|
||||
want to change the time.
|
||||
|
||||
### C. Open Container Manager → Project → Create
|
||||
|
||||
1. **Project name:** `recurring-checklist`
|
||||
2. **Path:** browse to `/volume1/docker/recurring-checklist/`
|
||||
3. **Source:** "Use existing docker-compose.yml"
|
||||
4. Click **Next** through the rest and **Done**. Container Manager will
|
||||
build the image (takes 30–90 seconds the first time) and start it.
|
||||
|
||||
### D. Open it in a browser
|
||||
|
||||
From any device on your network:
|
||||
|
||||
```
|
||||
http://<your-nas-ip>:3000
|
||||
```
|
||||
|
||||
You'll be asked for a name (your name shows up in the weekly email).
|
||||
That's it — start checking things off.
|
||||
|
||||
### E. Log in as admin to create lists
|
||||
|
||||
- Top-right of the page → **🔒 Admin** → enter the `ADMIN_PASSWORD` from `.env`.
|
||||
- A new bar appears under the list tabs. Pull it down (click or swipe) to
|
||||
reveal **+ Add List**, **Rename**, **Delete**, **Export/Import JSON**,
|
||||
and **Send Digest Now** (use this to test email).
|
||||
- In **Add Task**, build out your Startup and Shutdown lists.
|
||||
- Click 🔓 Admin again to leave admin mode so the controls hide again.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test the email before Sunday
|
||||
|
||||
While logged in as admin, hit **Send Digest Now** in the menu drawer.
|
||||
You should get an email within a few seconds. If not, check the container
|
||||
logs in Container Manager (it'll print the SMTP error).
|
||||
|
||||
---
|
||||
|
||||
## 4. Optional: HTTPS via Synology reverse proxy
|
||||
|
||||
In **Control Panel → Login Portal → Advanced → Reverse Proxy**:
|
||||
|
||||
| Source | Destination |
|
||||
| --- | --- |
|
||||
| `https://checklist.your-domain.com` (443) | `http://localhost:3000` |
|
||||
|
||||
Synology handles the Let's Encrypt cert. The app itself only listens on
|
||||
plain HTTP inside the container — TLS terminates at DSM.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running outside Docker (e.g. for local development)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
cp .env.example .env # then edit
|
||||
npm start
|
||||
```
|
||||
|
||||
Open <http://localhost:3000>.
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
Everything that matters lives in `data/checklists.json`. Add that path
|
||||
(`/volume1/docker/recurring-checklist/data`) to your Hyper Backup task
|
||||
and you're set.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Email never arrives.** Inside Container Manager, click the container →
|
||||
Log. Look for `[digest] Failed: ...`. Common causes:
|
||||
- Wrong Gmail app password.
|
||||
- 2-Step Verification not enabled on the Gmail account.
|
||||
- Synology firewall blocking outbound port 587.
|
||||
- **"Wrong password" when trying admin login.** Check `ADMIN_PASSWORD` in
|
||||
`.env` and that you restarted the container after editing.
|
||||
- **Port 3000 is taken.** Edit `docker-compose.yml` and change the left
|
||||
side of the port mapping (e.g. `"8088:3000"`), then redeploy.
|
||||
- **State got messed up.** Stop the container, edit
|
||||
`data/checklists.json` directly, restart.
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
checklist:
|
||||
build: .
|
||||
image: recurring-checklist:latest
|
||||
container_name: recurring-checklist
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Host port : container port. Change the left side if 3000 is taken.
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Persisted checklist data. On Synology, point this at something like
|
||||
# /volume1/docker/recurring-checklist/data
|
||||
- ./data:/app/data
|
||||
Generated
+886
@@ -0,0 +1,886 @@
|
||||
{
|
||||
"name": "recurring-checklist-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "recurring-checklist-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^8.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "recurring-checklist-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted web version of the Recurring Checklist app with weekly email digest",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^8.0.7"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Checklist">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#39a8ff"/>
|
||||
<stop offset="1" stop-color="#2ecc71"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#g)"/>
|
||||
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#ffffff" stroke-width="46" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1,798 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- Stop iOS Safari's data detectors from auto-linking IPs/ports/credentials in
|
||||
task descriptions as "phone numbers" — that drew a stray underline and hijacked
|
||||
taps into the call/share sheet. Nothing in this app is ever a phone number. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<title>Recurring Checklist</title>
|
||||
|
||||
<!-- Inter — modern productivity typeface (falls back to the system stack offline) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
|
||||
<!-- PWA / installable web app -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1d2026">
|
||||
<meta name="color-scheme" content="dark">
|
||||
|
||||
<!-- iOS Safari support -->
|
||||
<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="Checklist">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2">
|
||||
|
||||
<!-- Favicons (v2 = new checkmark logo; query busts the browser's favicon cache) -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg?v=2">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading checklist...</div>
|
||||
</div>
|
||||
|
||||
<!-- Shown when offline or while queued check-offs are syncing. -->
|
||||
<div id="offlineBadge" class="offline-badge" style="display:none;"></div>
|
||||
|
||||
<!-- First-launch setup, native apps only. The store build ships to anyone, so the
|
||||
server is a per-install choice rather than something baked into the binary. -->
|
||||
<div id="setupOverlay" class="setup-overlay" style="display:none;">
|
||||
<div class="setup-card">
|
||||
<div class="setup-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs><linearGradient id="sg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#39a8ff"/><stop offset="1" stop-color="#2ecc71"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#sg)"/>
|
||||
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#fff" stroke-width="46"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Set up Checklist</h2>
|
||||
<p class="setup-sub">How do you want to use it? You can change this later.</p>
|
||||
|
||||
<div id="setupChoices">
|
||||
<button type="button" class="setup-choice" data-setup="local">
|
||||
<span class="setup-choice-title">Just on this device</span>
|
||||
<span class="setup-choice-desc">Everything stays on your phone. No server,
|
||||
no account, works offline — but only you see it.</span>
|
||||
</button>
|
||||
<button type="button" class="setup-choice" data-setup="join">
|
||||
<span class="setup-choice-title">Join a server</span>
|
||||
<span class="setup-choice-desc">Your church already runs one. Everyone on your
|
||||
team shares the same lists.</span>
|
||||
</button>
|
||||
<button type="button" class="setup-choice" data-setup="host">
|
||||
<span class="setup-choice-title">Host it yourself</span>
|
||||
<span class="setup-choice-desc">Run it on your own computer or NAS. Your data
|
||||
stays on your hardware.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Address entry, shared by both choices above. -->
|
||||
<div id="setupJoin" style="display:none;">
|
||||
<label for="setupAddress">Server address</label>
|
||||
<input id="setupAddress" type="url" inputmode="url" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false" placeholder="checklist.yourchurch.org" />
|
||||
<div id="setupError" class="setup-error" style="display:none;"></div>
|
||||
<div class="setup-actions">
|
||||
<button type="button" id="setupBack" class="btn btn-secondary btn-small">Back</button>
|
||||
<button type="button" id="setupConnect" class="btn btn-primary btn-small">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="setupHostInfo" style="display:none;">
|
||||
<p class="setup-sub" style="text-align:left;">
|
||||
Checklist runs as a small Docker service on any always-on machine — a NAS, a
|
||||
spare computer, or a cheap VPS. Once it is running, come back and enter its
|
||||
address.
|
||||
</p>
|
||||
<div class="setup-actions">
|
||||
<button type="button" id="setupHostBack" class="btn btn-secondary btn-small">Back</button>
|
||||
<button type="button" id="setupHostNext" class="btn btn-primary btn-small">I have it running</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loginModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal">
|
||||
<h3>Sign in</h3>
|
||||
<div class="form-group">
|
||||
<label for="loginLocation">Location</label>
|
||||
<select id="loginLocation"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginTeam">Team</label>
|
||||
<select id="loginTeam"></select>
|
||||
</div>
|
||||
<div class="form-group" id="loginExecNameGroup" style="display:none;">
|
||||
<label for="loginExecName">Who are you?</label>
|
||||
<input id="loginExecName" type="text" autocomplete="off" autocapitalize="words" placeholder="Your name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">Password</label>
|
||||
<div class="password-field">
|
||||
<input id="loginPassword" type="password" autocomplete="current-password" />
|
||||
<button id="loginPasswordToggle" class="password-toggle" type="button" aria-label="Show password" title="Show password">🐵</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loginError" class="modal-message danger" style="display:none;"></div>
|
||||
<p style="font-size:11px;color:#888;margin:0;">Your location, team, and password. Editors: choose <code>Executive</code>. <a href="/privacy" target="_blank" rel="noopener noreferrer" style="color:#7c9cc4;">Privacy</a><span id="changeServerWrap" style="display:none;"> · <a href="#" id="changeServerLink" style="color:#7c9cc4;">Change server</a></span></p>
|
||||
<div class="modal-actions">
|
||||
<button id="loginSubmit" class="btn btn-primary btn-small" type="button">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="userMgmtModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>Manage Access</h3>
|
||||
<button id="userMgmtClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<div id="userMgmtError" class="modal-message danger" style="display:none;"></div>
|
||||
<div class="mgmt-grid">
|
||||
|
||||
<section class="mgmt-section">
|
||||
<h4>Team logins</h4>
|
||||
<input id="userMgmtSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by location, team, or email…" />
|
||||
<div id="userMgmtList" class="user-mgmt-list"></div>
|
||||
<div class="mgmt-add">
|
||||
<h5>Add a team login</h5>
|
||||
<div class="user-mgmt-grid">
|
||||
<select id="newUserLocation" aria-label="Location"></select>
|
||||
<select id="newUserTeam" aria-label="Team"></select>
|
||||
<input id="newUserPassword" type="password" placeholder="Password (4+ chars)" />
|
||||
<input id="newUserEmails" type="text" placeholder="Report email(s), comma-separated — optional" />
|
||||
<button id="newUserCreate" class="btn btn-primary btn-small" type="button">Create login</button>
|
||||
</div>
|
||||
<p class="mgmt-tip">One shared login per location + team. Report emails get the weekly digest.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mgmt-section">
|
||||
<h4>Executives</h4>
|
||||
<input id="execSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by name…" />
|
||||
<div id="execList" class="user-mgmt-list"></div>
|
||||
<div class="mgmt-add">
|
||||
<h5>Add an executive</h5>
|
||||
<div class="user-mgmt-grid">
|
||||
<input id="newExecName" type="text" placeholder="Name (e.g. Jordan)" autocapitalize="words" />
|
||||
<input id="newExecPassword" type="password" placeholder="Password (4+ chars)" />
|
||||
<button id="newExecCreate" class="btn btn-primary btn-small" type="button">Create executive</button>
|
||||
</div>
|
||||
<p class="mgmt-tip">Sign in under “Executive” with their name. “Edit access” sets the locations/teams they can edit.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mgmt-section mgmt-wide">
|
||||
<h4>Locations & teams</h4>
|
||||
<div id="directoryEditor" class="directory-editor"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="listModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal">
|
||||
<h3 id="listModalTitle"></h3>
|
||||
<div id="listModalMessage" class="modal-message" style="display:none;"></div>
|
||||
<input id="listModalInput" type="text" placeholder="List name" />
|
||||
<div id="listModalTemplateGroup" class="form-group" style="margin-top:8px; display:none;">
|
||||
<label for="listModalTemplate" style="font-size:12px;color:#b0b0b0;">Start from</label>
|
||||
<select id="listModalTemplate"></select>
|
||||
</div>
|
||||
<div id="listModalLocationGroup" class="form-group" style="margin-top:8px;">
|
||||
<label for="listModalLocation" style="font-size:12px;color:#b0b0b0;">Assign to location</label>
|
||||
<select id="listModalLocation"></select>
|
||||
</div>
|
||||
<div id="listModalTeamGroup" class="form-group" style="margin-top:8px;">
|
||||
<label for="listModalTeam" style="font-size:12px;color:#b0b0b0;">Assign to team</label>
|
||||
<select id="listModalTeam"></select>
|
||||
</div>
|
||||
<div id="listModalColorGroup" class="form-group" style="margin-top:8px;">
|
||||
<label style="font-size:12px;color:#b0b0b0;">Color</label>
|
||||
<div id="listModalColors" class="color-swatches">
|
||||
<button type="button" class="swatch swatch-none" data-color="" title="No color"></button>
|
||||
<button type="button" class="swatch" data-color="blue" title="Blue"></button>
|
||||
<button type="button" class="swatch" data-color="green" title="Green"></button>
|
||||
<button type="button" class="swatch" data-color="amber" title="Amber"></button>
|
||||
<button type="button" class="swatch" data-color="red" title="Red"></button>
|
||||
<button type="button" class="swatch" data-color="purple" title="Purple"></button>
|
||||
<button type="button" class="swatch" data-color="teal" title="Teal"></button>
|
||||
<button type="button" class="swatch" data-color="pink" title="Pink"></button>
|
||||
<button type="button" class="swatch" data-color="slate" title="Slate"></button>
|
||||
<button type="button" class="swatch" data-color="orange" title="Orange"></button>
|
||||
<button type="button" class="swatch" data-color="yellow" title="Yellow"></button>
|
||||
<button type="button" class="swatch" data-color="lime" title="Lime"></button>
|
||||
<button type="button" class="swatch" data-color="cyan" title="Cyan"></button>
|
||||
<button type="button" class="swatch" data-color="indigo" title="Indigo"></button>
|
||||
<button type="button" class="swatch" data-color="brown" title="Brown"></button>
|
||||
<label class="swatch swatch-custom" title="Custom color — pick your own">
|
||||
<input type="color" id="listModalCustomColor" value="#3498db" aria-label="Custom color" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="listModalHiddenGroup" class="form-group" style="margin-top:10px;">
|
||||
<label class="checkbox-label" style="font-size:13px;"><input type="checkbox" id="listModalHidden" /> Hidden — only editors see it</label>
|
||||
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalShowNotes" /> Show a Notes column — reference info, no checking off</label>
|
||||
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalNotesOnly" /> Notes only — hide the To Do and Done columns</label>
|
||||
<label class="checkbox-label" style="font-size:13px;margin-top:6px;"><input type="checkbox" id="listModalShowFeedback" /> Show a Feedback column — anyone on the list can add input</label>
|
||||
<div class="form-group" id="listModalSectionsGroup" style="margin-top:10px;">
|
||||
<label style="font-size:13px;">Task sections — extra To Do columns (e.g. Monday, Tuesday). The first is the default and can be renamed.</label>
|
||||
<div id="listModalSections"></div>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="listModalSectionAdd">+ Add section</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="listModalRepeatGroup" class="form-group" style="margin-top:8px;">
|
||||
<label for="listModalResetType" style="font-size:12px;color:#b0b0b0;">🔁 Repeat this list <span style="color:#7c828c;">— every item resets to To Do on this schedule</span></label>
|
||||
<select id="listModalResetType">
|
||||
<option value="">Don't repeat — reset manually</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
<div id="listModalResetEvery" class="reset-row" style="display:none;">
|
||||
<span>Every</span>
|
||||
<input type="number" id="listModalResetInterval" min="1" max="99" value="1" />
|
||||
<span id="listModalResetUnit">weeks</span>
|
||||
<span id="listModalResetHint" class="reset-hint"></span>
|
||||
</div>
|
||||
<div id="listModalResetStartRow" class="reset-row" style="display:none;">
|
||||
<span>starting on</span>
|
||||
<select id="listModalResetStartDow">
|
||||
<option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option>
|
||||
<option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="listModalResetDowRow" class="reset-row" style="display:none;">
|
||||
<span>on</span>
|
||||
<select id="listModalResetDow">
|
||||
<option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option>
|
||||
<option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="listModalResetMonthRow" class="reset-row" style="display:none;">
|
||||
<span>in</span>
|
||||
<select id="listModalResetMonth">
|
||||
<option value="0">January</option><option value="1">February</option><option value="2">March</option>
|
||||
<option value="3">April</option><option value="4">May</option><option value="5">June</option>
|
||||
<option value="6">July</option><option value="7">August</option><option value="8">September</option>
|
||||
<option value="9">October</option><option value="10">November</option><option value="11">December</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="listModalResetDomRow" class="reset-row" style="display:none;">
|
||||
<span>on day</span>
|
||||
<input type="number" id="listModalResetDom" min="1" max="31" value="1" />
|
||||
<span>of the month</span>
|
||||
</div>
|
||||
<div id="listModalResetTimesRow" class="reset-row reset-times-row" style="display:none;">
|
||||
<span>at</span>
|
||||
<div id="listModalResetTimesList" class="reset-times-list"></div>
|
||||
<button type="button" id="listModalResetTimesAdd" class="btn btn-secondary btn-small reset-time-add">+ Add time</button>
|
||||
<span class="reset-hint reset-times-hint">leave empty to reset once a day</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="listModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
|
||||
<button id="listModalSave" class="btn btn-primary btn-small" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editTaskModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal wide">
|
||||
<h3>Edit Task</h3>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="editTitle">Title</label>
|
||||
<input id="editTitle" type="text" placeholder="Task title..." />
|
||||
</div>
|
||||
<div class="form-group" id="editGroupRow" style="display:none;">
|
||||
<label for="editGroupSelect">Section</label>
|
||||
<select id="editGroupSelect"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editDescription">Description</label>
|
||||
<div class="md-toolbar" data-md-target="editDescription">
|
||||
<button type="button" class="md-btn" data-md="bold" title="Bold"><b>B</b></button>
|
||||
<button type="button" class="md-btn" data-md="italic" title="Italic"><i>I</i></button>
|
||||
<button type="button" class="md-btn" data-md="h" title="Heading">H</button>
|
||||
<button type="button" class="md-btn" data-md="ul" title="Bullet list">• List</button>
|
||||
<button type="button" class="md-btn" data-md="ol" title="Numbered list">1. List</button>
|
||||
<button type="button" class="md-btn" data-md="task" title="Checkbox — a step users tick off (the task auto-completes when all are checked)">☑ Step</button>
|
||||
<button type="button" class="md-btn" data-md="link" title="Add a link (web page, Google Doc…)">🔗 Link</button>
|
||||
<button type="button" class="md-btn" data-md="attach" title="Attach a document (PDF, Word, Excel, PowerPoint…) — uploads it and links it here">📎 File</button>
|
||||
<button type="button" class="md-btn md-preview-btn" data-md="preview" aria-pressed="false" title="Toggle formatted markdown view">Markdown</button>
|
||||
</div>
|
||||
<textarea id="editDescription" placeholder="Description — format with the buttons above." style="display:none;"></textarea>
|
||||
<div class="md-rich" contenteditable="true" data-md-rich-for="editDescription" data-placeholder="Description — format with the buttons above." role="textbox" aria-multiline="true"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" id="editIsRecurring" /> Recurring Task</label>
|
||||
</div>
|
||||
<div class="form-group" id="editRecurrenceOptions" style="display: none;">
|
||||
<div class="rec-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="editModalCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
|
||||
<button id="editModalSave" class="btn btn-primary btn-small" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="moveItemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal">
|
||||
<h3>Move Task</h3>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="moveItemSelect">Select destination list</label>
|
||||
<select id="moveItemSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="moveItemCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
|
||||
<button id="moveItemSave" class="btn btn-primary btn-small" type="button">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline "Move to…" dropdown anchored to a task's Move button (replaces the modal flow). -->
|
||||
<div id="moveMenuBackdrop" class="move-menu-backdrop" style="display:none;"></div>
|
||||
<div id="moveMenu" class="move-menu" role="menu" aria-label="Move task to list" style="display:none;"></div>
|
||||
|
||||
<!-- Cross-department request (editors only): lands in the target team's Requests inbox. -->
|
||||
<div id="requestModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>✋ Request something</h3></div>
|
||||
<div class="modal-body">
|
||||
<div id="requestError" class="modal-message danger" style="display:none;"></div>
|
||||
<div class="form-group">
|
||||
<label for="requestTarget">From which department?</label>
|
||||
<select id="requestTarget"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="requestTitle">What do you need?</label>
|
||||
<input id="requestTitle" type="text" maxlength="120" placeholder="e.g. Toilet paper restock in the office bathroom" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="requestDetails">Details (optional)</label>
|
||||
<textarea id="requestDetails" rows="3" maxlength="2000" placeholder="Where exactly, how urgent, anything helpful…"></textarea>
|
||||
</div>
|
||||
<div id="requestPhotos" class="equip-photos"></div>
|
||||
<div class="equip-photo-row">
|
||||
<label class="btn btn-secondary btn-small equip-upload-label">
|
||||
📷 Add photo
|
||||
<input id="requestPhotoFile" type="file" accept="image/*" hidden />
|
||||
</label>
|
||||
<span id="requestUploadStatus" class="equip-upload-status"></span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
|
||||
<button id="requestCancel" class="btn btn-secondary" type="button">Cancel</button>
|
||||
<button id="requestSend" class="btn btn-primary" type="button">Send request</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment photo popup (shown when anyone taps an equipment word).
|
||||
The image sits in a pan/zoom stage: pinch or scroll to zoom, drag to pan,
|
||||
double-tap to reset. The note panel below scrolls independently. -->
|
||||
<div id="equipModal" class="modal-overlay equip-lightbox" style="display: none;">
|
||||
<div class="modal equip-modal">
|
||||
<div class="equip-lb-head">
|
||||
<h3 id="equipModalName"></h3>
|
||||
<button id="equipReportBtn" class="equip-report-btn" type="button" title="Report a problem with this gear">⚠ Report a problem</button>
|
||||
<button id="equipModalClose" class="equip-lb-close" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div id="equipStage" class="equip-stage">
|
||||
<img id="equipModalImg" class="equip-img" alt="" draggable="false" />
|
||||
<button id="equipPrev" class="equip-nav equip-nav-prev" type="button" aria-label="Previous photo">‹</button>
|
||||
<button id="equipNext" class="equip-nav equip-nav-next" type="button" aria-label="Next photo">›</button>
|
||||
<div id="equipZoomHint" class="equip-zoom-hint"></div>
|
||||
<div class="equip-zoom-controls">
|
||||
<button id="equipZoomOut" class="equip-zoom-btn" type="button" aria-label="Zoom out">−</button>
|
||||
<button id="equipZoomReset" class="equip-zoom-btn" type="button" aria-label="Fit to screen">⤢</button>
|
||||
<button id="equipZoomIn" class="equip-zoom-btn" type="button" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="equipCaption" class="equip-caption"></div>
|
||||
<div id="equipThumbs" class="equip-thumbs"></div>
|
||||
<div id="equipModalNote" class="equip-note"></div>
|
||||
<div id="equipIssues" class="equip-issues"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment library manager (master only) -->
|
||||
<div id="equipMgmtModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>Equipment Library</h3>
|
||||
<button id="equipMgmtClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<div id="equipMgmtError" class="modal-message danger" style="display:none;"></div>
|
||||
<p class="mgmt-tip">Add a photo for any gear — its name becomes tappable in descriptions for the locations/teams you pick. Each location can keep its own photo.</p>
|
||||
<input id="equipSearch" class="mgmt-search" type="search" placeholder="🔍 Filter equipment…" />
|
||||
<div id="equipMgmtList" class="user-mgmt-list"></div>
|
||||
<div class="mgmt-add" id="equipAddSection">
|
||||
<button type="button" class="mgmt-add-toggle" id="equipAddToggle" aria-expanded="false" aria-controls="equipAddBody">
|
||||
<span class="mgmt-add-caret" aria-hidden="true">+</span>
|
||||
<span id="equipFormTitle">Add equipment</span>
|
||||
</button>
|
||||
<div class="mgmt-add-body" id="equipAddBody">
|
||||
<div class="user-mgmt-grid">
|
||||
<input id="equipName" type="text" placeholder="Name" />
|
||||
<input id="equipAliases" type="text" placeholder="Aliases, comma-separated (optional)" />
|
||||
<textarea id="equipNote" class="equip-note-input" rows="2" placeholder="Note / troubleshooting steps (optional) — Markdown & line breaks supported"></textarea>
|
||||
</div>
|
||||
<div id="equipPhotos" class="equip-photos"></div>
|
||||
<div class="equip-photo-row">
|
||||
<label class="btn btn-secondary btn-small equip-upload-label">
|
||||
📁 Add photo / video
|
||||
<input id="equipFile" type="file" accept="image/*,video/mp4,video/webm,video/quicktime" hidden />
|
||||
</label>
|
||||
<button id="equipCamBtn" class="btn btn-secondary btn-small" type="button">📷 Take photo</button>
|
||||
<span id="equipUploadStatus" class="equip-upload-status"></span>
|
||||
</div>
|
||||
<div class="equip-url-row">
|
||||
<input id="equipImage" type="text" class="equip-url-input" placeholder="…or paste an image / video URL (JPG, PNG, MP4)" />
|
||||
<button id="equipAddUrl" class="btn btn-secondary btn-small" type="button">Add</button>
|
||||
</div>
|
||||
<div id="equipDocs" class="equip-docs-editor"></div>
|
||||
<div class="equip-photo-row">
|
||||
<label class="btn btn-secondary btn-small equip-upload-label">
|
||||
📎 Add document
|
||||
<input id="equipDocFile" type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.rtf" hidden />
|
||||
</label>
|
||||
</div>
|
||||
<div class="equip-url-row">
|
||||
<input id="equipDocUrl" type="text" class="equip-url-input" placeholder="…or paste a link (Google Doc, manual, web page)" />
|
||||
<button id="equipDocAdd" class="btn btn-secondary btn-small" type="button">Add link</button>
|
||||
</div>
|
||||
<div class="equip-scope">
|
||||
<div id="equipScopeHelp" class="exec-access-help">Where it appears — check location(s) and/or team(s).</div>
|
||||
<div class="dir-head">Locations</div>
|
||||
<div id="equipLocRow" class="exec-cb-row"></div>
|
||||
<div class="dir-head" style="margin-top:6px;">Teams</div>
|
||||
<div id="equipTeamRow" class="exec-cb-row"></div>
|
||||
</div>
|
||||
<div class="equip-form-actions">
|
||||
<button id="equipSave" class="btn btn-primary btn-small" type="button">Add equipment</button>
|
||||
<button id="equipCancelEdit" class="btn btn-secondary btn-small" type="button" style="display:none;">Cancel edit</button>
|
||||
</div>
|
||||
</div><!-- /.mgmt-add-body -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In-app camera capture (works on desktop + mobile via getUserMedia) -->
|
||||
<div id="equipCamModal" class="modal-overlay" style="display:none; z-index:10002;">
|
||||
<div class="modal equip-cam">
|
||||
<div class="mgmt-head">
|
||||
<h3>Take a photo</h3>
|
||||
<button id="equipCamCancel" class="btn btn-secondary btn-small" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="equip-cam-stage">
|
||||
<video id="equipCamVideo" playsinline autoplay muted></video>
|
||||
</div>
|
||||
<div id="equipCamError" class="modal-message danger" style="display:none;"></div>
|
||||
<div class="equip-cam-actions">
|
||||
<button id="equipCamCapture" class="btn btn-primary" type="button">📸 Capture</button>
|
||||
<button id="equipCamSwitch" class="btn btn-secondary btn-small" type="button">🔄 Switch camera</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview: each team's progress today + recent activity (master + execs) -->
|
||||
<div id="overviewModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>Today at a glance</h3>
|
||||
<button id="overviewClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<div id="overviewBody" class="overview-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>🛡 Activity Log</h3>
|
||||
<button id="activityClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<input id="activitySearch" class="activity-search" type="search" placeholder="Filter by person, action, or list…" autocomplete="off" />
|
||||
<div id="activityBody" class="activity-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- "Who's checking?" name picker (shared team logins only) -->
|
||||
<div id="checkerModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h3>Who's checking?</h3>
|
||||
<p style="font-size:12px;color:#888;margin:0;">Pick your name so your check-offs are recorded as you — no password needed.</p>
|
||||
<select id="checkerSelect" style="margin-top:8px;"></select>
|
||||
<div id="checkerAddRow" style="display:none; gap:8px; margin-top:8px;">
|
||||
<input id="checkerNewName" type="text" placeholder="Type your name" autocapitalize="words" autocomplete="off" style="flex:1;" />
|
||||
<button id="checkerAddBtn" class="btn btn-primary btn-small" type="button">Add</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="checkerClose" class="btn btn-secondary btn-small" type="button">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team name rosters — manage who's on each team (master + execs) -->
|
||||
<div id="rosterModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>Team names</h3>
|
||||
<button id="rosterClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<p class="mgmt-tip">Names checkers can tap when signing in. Add or remove names for any team you manage.</p>
|
||||
<input id="rosterSearch" class="mgmt-search" type="search" placeholder="🔍 Filter by location or team…" />
|
||||
<div id="rosterBody" class="roster-body user-mgmt-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="app-logo" viewBox="0 0 44 44" role="img" aria-label="Checklist" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="appLogoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#39a8ff"/>
|
||||
<stop offset="100%" stop-color="#2ecc71"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="3" y="3" width="38" height="38" rx="12" fill="url(#appLogoGrad)"/>
|
||||
<path class="app-logo-check" d="M13.5 22.5 L19.5 28.5 L30.5 15.5" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-controls admin-only">
|
||||
<div class="list-tabs-controls">
|
||||
<button id="listViewToggle" class="lists-toggle" type="button" title="Switch between the tab strip and a Location → Team → List tree">🌳 Tree view</button>
|
||||
<button id="listTabsToggle" class="lists-toggle" type="button" aria-expanded="true" title="Show/hide the lists">Hide lists</button>
|
||||
<div class="list-filter master-only">
|
||||
<select id="adminFilterLocation" aria-label="Filter by location"></select>
|
||||
<select id="adminFilterTeam" aria-label="Filter by team"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="undo-redo admin-only" role="group" aria-label="Undo and redo">
|
||||
<button id="undoBtn" class="icon-btn" type="button" title="Undo (Ctrl/⌘+Z)" aria-label="Undo" disabled>↶</button>
|
||||
<button id="redoBtn" class="icon-btn" type="button" title="Redo (Ctrl/⌘+Shift+Z)" aria-label="Redo" disabled>↷</button>
|
||||
</div>
|
||||
<div id="listMenu" class="list-menu">
|
||||
<button id="listMenuHandle" class="list-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="listMenuPanel" aria-label="List tools menu"><span class="lmh-icon" aria-hidden="true">☰</span><span class="lmh-label">Menu</span></button>
|
||||
<div id="listMenuBackdrop" class="list-menu-backdrop"></div>
|
||||
<div id="listMenuPanel" class="list-menu-panel" role="menu" aria-label="List tools">
|
||||
<div class="menu-group-label">This list</div>
|
||||
<button id="addTaskBtn" class="btn btn-primary btn-small">+ Add Task</button>
|
||||
<button id="addNoteBtn" class="btn btn-secondary btn-small" title="Add a reference note (no checkbox)">+ Add Note</button>
|
||||
<button id="renameListBtn" class="btn btn-secondary btn-small">✏️ Edit List</button>
|
||||
<button id="duplicateListBtn" class="btn btn-secondary btn-small" title="Make a copy of this list (items copied, checkmarks fresh)">⧉ Duplicate List</button>
|
||||
<button id="resetListBtn" class="btn btn-secondary btn-small" title="Move every item back to To Do">↺ Reset List</button>
|
||||
<button id="deleteListBtn" class="btn btn-danger btn-small">🗑 Delete List</button>
|
||||
<div class="menu-group-label">Lists</div>
|
||||
<button id="addListBtn" class="btn btn-primary btn-small">+ Add List</button>
|
||||
<div class="menu-group-label">Tools</div>
|
||||
<button id="helpTipsBtn" class="btn btn-secondary btn-small" type="button" title="Quick tips on how this works">❔ How this works</button>
|
||||
<button id="overviewBtn" class="btn btn-secondary btn-small admin-only" title="See each team's progress today">📊 Overview</button>
|
||||
<button id="rosterBtn" class="btn btn-secondary btn-small admin-only" title="Manage the names checkers can pick">🧑 Team Names</button>
|
||||
<button id="manageUsersBtn" class="btn btn-secondary btn-small master-only">👥 Manage Users</button>
|
||||
<button id="activityBtn" class="btn btn-secondary btn-small master-only" title="See everything done in the app — who checked, created, edited, moved, or deleted">🛡 Activity Log</button>
|
||||
<button id="incidentsBtn" class="btn btn-secondary btn-small admin-only" title="See and resolve reported gear problems in your area">⚠ Incidents<span id="incidentsBtnCount" class="menu-count" style="display:none;"></span></button>
|
||||
<button id="reliabilityBtn" class="btn btn-secondary btn-small admin-only" title="Which gear breaks most — ranked by reported issues">📊 Reliability</button>
|
||||
<button id="equipMgmtBtn" class="btn btn-secondary btn-small admin-only">🔧 Equipment</button>
|
||||
<button id="sendDigestBtn" class="btn btn-secondary btn-small admin-only" title="Email the checklist report now to the configured recipients for your area">📧 Send Report</button>
|
||||
<button id="exportJsonBtn" class="btn btn-secondary btn-small admin-only" title="Download a backup of your lists (master = full backup)">⬇ Export JSON</button>
|
||||
<button id="importJsonBtn" class="btn btn-secondary btn-small admin-only">⬆ Import JSON</button>
|
||||
<input id="importJsonInput" type="file" accept="application/json,.json" style="display:none;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="searchBtn" class="icon-btn search-bar-btn" type="button" title="Search gear, notes & tasks (press /)" aria-label="Search"><span class="sb-icon" aria-hidden="true">🔍</span><span class="sb-label">Search…</span></button>
|
||||
<button id="reportIssueBtn" class="icon-btn report-issue-btn" type="button" title="Report a gear problem" aria-label="Report a gear problem">⚠</button>
|
||||
<button id="requestsBadge" class="requests-badge" type="button" style="display:none;" title="Open requests waiting — tap to view" aria-label="Open requests">✋ <span id="requestsBadgeCount">0</span></button>
|
||||
<button id="requestSendBtn" class="icon-btn request-send-btn admin-only" type="button" title="Request something from another department" aria-label="Request something from another department">✋</button>
|
||||
<button id="checkerChip" class="checker-chip checker-only" type="button" title="Who's checking? Tap to pick your name">
|
||||
<span class="checker-chip-icon">👤</span>
|
||||
<span id="checkerChipName">Who's checking?</span>
|
||||
</button>
|
||||
<button id="userBadge" class="user-badge" type="button" title="Click to sign out">
|
||||
<span class="user-badge-icon">👤</span>
|
||||
<span id="userBadgeName">Sign in</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div id="tasksView" class="tab-view active">
|
||||
<div class="list-tabs-row">
|
||||
<div id="activeListName" class="active-list-name admin-only"></div>
|
||||
<div id="listTabs" class="list-tabs"></div>
|
||||
<div id="listTree" class="list-tree admin-only"></div>
|
||||
</div>
|
||||
<div class="checklists-section">
|
||||
<div class="checklist-column" data-col="todo">
|
||||
<h2><span class="col-title">To Do</span></h2>
|
||||
<div id="todoList" class="checklist-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="checklist-column" data-col="done">
|
||||
<h2><span class="col-title">Done</span></h2>
|
||||
<div id="doneList" class="checklist-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="checklist-column notes-column" data-col="notes">
|
||||
<h2><span class="col-title">Notes</span></h2>
|
||||
<div id="notesList" class="checklist-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="checklist-column feedback-column" data-col="feedback">
|
||||
<h2><span class="col-title">Feedback</span></h2>
|
||||
<div id="feedbackList" class="checklist-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="searchOverlay" class="search-overlay" style="display:none;">
|
||||
<div class="search-panel" role="dialog" aria-label="Search">
|
||||
<div class="search-inputrow">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input id="globalSearchInput" type="search" placeholder="Search gear, notes, tasks & lists…" autocomplete="off" />
|
||||
<button id="searchClose" type="button" class="icon-btn" aria-label="Close search">✕</button>
|
||||
</div>
|
||||
<div id="searchResults" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report a gear problem (any signed-in user) -->
|
||||
<div id="incidentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>⚠ Report a problem</h3>
|
||||
<button id="incidentClose" class="btn btn-secondary btn-small" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="incidentGearRow" class="incident-gear-row" style="display:none;">
|
||||
<span class="incident-gear-label">Gear:</span>
|
||||
<span id="incidentGearName" class="incident-gear-name"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>How bad is it?</label>
|
||||
<div class="severity-toggle" id="incidentSeverity">
|
||||
<button type="button" class="sev-btn sev-down" data-sev="down">
|
||||
<span class="sev-emoji">🔴</span>
|
||||
<span class="sev-title">It's down now</span>
|
||||
<span class="sev-sub">Can't be used — alerts the team right away</span>
|
||||
</button>
|
||||
<button type="button" class="sev-btn sev-minor is-selected" data-sev="minor">
|
||||
<span class="sev-emoji">🟡</span>
|
||||
<span class="sev-title">Minor issue</span>
|
||||
<span class="sev-sub">Still works — goes in the report</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="incidentWhat">What's wrong?</label>
|
||||
<input type="text" id="incidentWhat" maxlength="120" placeholder="e.g. HDMI port loose, won't hold signal" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="incidentDetails">Details <span class="label-hint">(optional)</span></label>
|
||||
<textarea id="incidentDetails" rows="3" maxlength="2000" placeholder="Anything that helps whoever fixes it…"></textarea>
|
||||
</div>
|
||||
<div id="incidentReportingAs" class="incident-reporting-as" style="display:none;"></div>
|
||||
<div class="modal-footer">
|
||||
<button id="incidentSubmit" class="btn btn-primary" type="button">Send report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reported gear problems (editors) -->
|
||||
<div id="incidentsModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>⚠ Gear Issues</h3>
|
||||
<button id="incidentsClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<div class="incidents-controls">
|
||||
<label class="incidents-openonly"><input type="checkbox" id="incidentsOpenOnly" checked /> Open only</label>
|
||||
<input type="search" id="incidentsSearch" class="incidents-search" placeholder="Filter by gear, place, or text…" autocomplete="off" />
|
||||
</div>
|
||||
<div id="incidentsList" class="incidents-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reliability dashboard (editors) -->
|
||||
<div id="reliabilityModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal mgmt">
|
||||
<div class="mgmt-head">
|
||||
<h3>📊 Gear Reliability</h3>
|
||||
<button id="reliabilityClose" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
</div>
|
||||
<div class="reliability-controls">
|
||||
<label class="incidents-openonly"><input type="checkbox" id="reliabilityOpenFirst" checked /> Sort by open issues</label>
|
||||
<span id="reliabilitySummary" class="reliability-summary"></span>
|
||||
</div>
|
||||
<div id="reliabilityList" class="reliability-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="addTaskModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal wide">
|
||||
<div class="add-header">
|
||||
<h3 id="addModalTitle">Add Task</h3>
|
||||
<div class="add-list-target">
|
||||
<label for="addListSelect">Add to list:</label>
|
||||
<select id="addListSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<input type="text" id="itemTitle" placeholder="Task title..." />
|
||||
</div>
|
||||
<div class="form-group" id="itemGroupRow" style="display:none;">
|
||||
<label for="itemGroupSelect">Section</label>
|
||||
<select id="itemGroupSelect"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="md-toolbar" data-md-target="itemDescription">
|
||||
<button type="button" class="md-btn" data-md="bold" title="Bold"><b>B</b></button>
|
||||
<button type="button" class="md-btn" data-md="italic" title="Italic"><i>I</i></button>
|
||||
<button type="button" class="md-btn" data-md="h" title="Heading">H</button>
|
||||
<button type="button" class="md-btn" data-md="ul" title="Bullet list">• List</button>
|
||||
<button type="button" class="md-btn" data-md="ol" title="Numbered list">1. List</button>
|
||||
<button type="button" class="md-btn" data-md="task" title="Checkbox — a step users tick off (the task auto-completes when all are checked)">☑ Step</button>
|
||||
<button type="button" class="md-btn" data-md="link" title="Add a link (web page, Google Doc…)">🔗 Link</button>
|
||||
<button type="button" class="md-btn" data-md="attach" title="Attach a document (PDF, Word, Excel, PowerPoint…) — uploads it and links it here">📎 File</button>
|
||||
<button type="button" class="md-btn md-preview-btn" data-md="preview" aria-pressed="false" title="Toggle formatted markdown view">Markdown</button>
|
||||
</div>
|
||||
<textarea id="itemDescription" placeholder="Description (optional)" style="display:none;"></textarea>
|
||||
<div class="md-rich" contenteditable="true" data-md-rich-for="itemDescription" data-placeholder="Description (optional)" role="textbox" aria-multiline="true"></div>
|
||||
</div>
|
||||
<div class="form-group" id="recurringFieldGroup">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="isRecurring" />
|
||||
Recurring Task
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="recurrenceOptions" style="display: none;">
|
||||
<div class="rec-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="addTaskCancel" class="btn btn-secondary btn-small" type="button">Close</button>
|
||||
<button id="addItemBtn" class="btn btn-primary btn-small">Add Task</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Motion One (vendored, self-contained ~15KB): exposes window.Motion synchronously
|
||||
before app.js so item FLIP, spring modals and the completion burst can use it.
|
||||
If it ever fails to load, app.js falls back to the pure-CSS animations. -->
|
||||
<script src="vendor/motion.global.js"></script>
|
||||
<!-- Native shim: a no-op on the web; inside the iOS/Android app bundles it points
|
||||
server-absolute URLs (/api/…, /equip-images/…) at the real server. The mobile
|
||||
build injects native-config.js + native-shell.js ahead of it. -->
|
||||
<script src="native-bridge.js"></script>
|
||||
<script src="md.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "Recurring Checklist",
|
||||
"short_name": "Checklist",
|
||||
"description": "Weekly startup and shutdown checklist for each location",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#14161b",
|
||||
"theme_color": "#1d2026",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Minimal, self-hosted Markdown renderer for task descriptions.
|
||||
* Safe by construction: every piece of source text is HTML-escaped BEFORE any
|
||||
* markup is applied, and only a fixed whitelist of tags is emitted
|
||||
* (h4–h6, p, br, ul/ol/li, strong, em, a[href to http/https/mailto]).
|
||||
* Equipment [[markers]] and auto-linked terms are intentionally LEFT as text
|
||||
* here — the app layers those on afterward via a safe text-node pass.
|
||||
* Exposes a global: window.MD.render(text) -> html string.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Inline formatting on already-escaped text.
|
||||
function inline(escaped) {
|
||||
var s = escaped;
|
||||
// links: [label](http(s)://…, mailto:…, or a site-relative /doc-files/… upload)
|
||||
// — label already escaped. /doc-files is the ONLY relative prefix allowed, so
|
||||
// the href whitelist stays as tight as before.
|
||||
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+|\/doc-files\/[^\s)]+)\)/g,
|
||||
function (m, label, url) {
|
||||
var cls = url.indexOf('/doc-files/') === 0 ? ' class="doc-link"' : '';
|
||||
return '<a href="' + url + '"' + cls + ' target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||
});
|
||||
// bold+italic ***text*** (must run before the ** and * rules)
|
||||
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
// bold **text**
|
||||
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
// italic *text* / _text_ (require a boundary so it doesn't eat stray asterisks)
|
||||
s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?])/g, '$1<em>$2</em>');
|
||||
s = s.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?])/g, '$1<em>$2</em>');
|
||||
return s;
|
||||
}
|
||||
|
||||
// CANONICAL task-list line regex. MUST stay byte-identical to server.js TASK_LINE_RE and
|
||||
// app.js parseTaskList — the count/index/label must agree across all three or per-location
|
||||
// checkmarks misalign. Group 1 = mark char ( / x / X), group 2 = label (may be empty).
|
||||
var TASK_RE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
|
||||
|
||||
function render(src) {
|
||||
var lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
||||
var out = [];
|
||||
var para = [];
|
||||
var taskIndex = 0; // 0-based position among task-list lines, in document order (per render)
|
||||
function flushPara() {
|
||||
if (para.length) { out.push('<p>' + para.map(inline).join('<br>') + '</p>'); para = []; }
|
||||
}
|
||||
var i = 0;
|
||||
while (i < lines.length) {
|
||||
var raw = lines[i];
|
||||
if (raw.trim() === '') { flushPara(); i++; continue; }
|
||||
|
||||
var hm = /^(#{1,3})\s+(.*)$/.exec(raw);
|
||||
if (hm) { flushPara(); var lvl = hm[1].length + 3; out.push('<h' + lvl + '>' + inline(escapeHtml(hm[2])) + '</h' + lvl + '>'); i++; continue; }
|
||||
|
||||
if (/^\s*[-*]\s+/.test(raw)) {
|
||||
flushPara();
|
||||
var ul = [];
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
var tm = TASK_RE.exec(lines[i]);
|
||||
if (tm) {
|
||||
// A checkbox line "- [ ] label". The box is an EMPTY, non-editable span (glyph
|
||||
// drawn in CSS — no unicode/emoji, iPad-safe) so it adds zero text: the WYSIWYG
|
||||
// round-trip (htmlToMarkdown) and equipment auto-linking both stay clean. The
|
||||
// label runs through inline(escapeHtml(...)) IDENTICALLY to a plain bullet, so
|
||||
// **bold**, links and equipment terms all work inside it. data-checked is the
|
||||
// AUTHORED mark (round-trip fidelity); the live view overrides the visual from
|
||||
// per-location state, keyed by data-task-index.
|
||||
var on = tm[1].toLowerCase() === 'x';
|
||||
ul.push('<li class="md-task' + (on ? ' is-done' : '') + '" data-task-index="' + (taskIndex++) +
|
||||
'" data-checked="' + (on ? 'true' : 'false') + '">' +
|
||||
'<span class="md-task-box" contenteditable="false" aria-hidden="true"></span>' +
|
||||
'<span class="md-task-label">' + inline(escapeHtml(tm[2])) + '</span></li>');
|
||||
} else {
|
||||
ul.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*[-*]\s+/, ''))) + '</li>');
|
||||
}
|
||||
i++;
|
||||
}
|
||||
out.push('<ul>' + ul.join('') + '</ul>');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(raw)) {
|
||||
flushPara();
|
||||
var ol = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
ol.push('<li>' + inline(escapeHtml(lines[i].replace(/^\s*\d+\.\s+/, ''))) + '</li>');
|
||||
i++;
|
||||
}
|
||||
out.push('<ol>' + ol.join('') + '</ol>');
|
||||
continue;
|
||||
}
|
||||
|
||||
para.push(escapeHtml(raw));
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
window.MD = { render: render, escapeHtml: escapeHtml };
|
||||
})();
|
||||
@@ -0,0 +1,257 @@
|
||||
// native-bridge.js — loaded by BOTH the website and the iOS/Android app bundles.
|
||||
//
|
||||
// The website and the two store apps run the exact same front-end files; this shim is the
|
||||
// only thing that knows the difference. In a browser it is a deliberate NO-OP: it defines
|
||||
// window.CHECKLIST_NATIVE with active:false and returns before touching anything. Every
|
||||
// behaviour change below happens only inside a Capacitor shell.
|
||||
//
|
||||
// Why it's needed: on the web the front-end and the API share an origin, so `/api/state`
|
||||
// and `/equip-images/x.jpg` just work. Inside a store app the front-end is served from the
|
||||
// app bundle (capacitor://localhost on iOS, https://localhost on Android) and the API lives
|
||||
// on a different origin, so every server-absolute path has to be re-pointed at the real
|
||||
// server. Rather than rewrite hundreds of call sites we patch fetch() once and expose a
|
||||
// resolveUrl() the handful of <img>/<a> sites use.
|
||||
//
|
||||
// The API base is injected by the mobile build (mobile/scripts/sync-web.js writes a
|
||||
// native-config.js into the bundle). It is intentionally NOT hard-coded here so the website
|
||||
// copy of this file carries no server URL and dev builds can point somewhere else.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.CHECKLIST_NATIVE_CONFIG || null; // present only in native bundles
|
||||
var cap = window.Capacitor;
|
||||
var isNative = !!(cap && typeof cap.isNativePlatform === 'function' && cap.isNativePlatform());
|
||||
|
||||
// Which server this install talks to.
|
||||
//
|
||||
// The app ships to anyone, not just Waters, so the server is a per-INSTALL choice rather
|
||||
// than something baked into the binary: the user picks it once at first launch. The
|
||||
// bundled apiBase is only a SUGGESTION pre-filled into that screen — using it
|
||||
// automatically would silently point every download at Waters' NAS.
|
||||
// Three modes, chosen once on the setup screen:
|
||||
// 'local' — no server at all; server.js itself runs in this WebView (local-server.js)
|
||||
// 'server' — talk to the address in checklist:server
|
||||
// '' — nothing chosen yet, show the setup screen
|
||||
var mode = '';
|
||||
var chosen = '';
|
||||
try {
|
||||
mode = localStorage.getItem('checklist:mode') || '';
|
||||
chosen = localStorage.getItem('checklist:server') || '';
|
||||
} catch (_) {}
|
||||
if (!mode && chosen) mode = 'server'; // installs from before local mode existed
|
||||
var base = String(chosen).replace(/\/+$/, '');
|
||||
|
||||
// Published surface. `active` is what app.js branches on; resolveUrl is safe to call
|
||||
// on the web too (it returns the path unchanged), so call sites need no platform test.
|
||||
var api = {
|
||||
active: isNative,
|
||||
platform: isNative && typeof cap.getPlatform === 'function' ? cap.getPlatform() : 'web',
|
||||
apiBase: isNative ? base : '',
|
||||
bundleVersion: (cfg && cfg.bundleVersion) || '',
|
||||
bundledAt: (cfg && cfg.bundledAt) || '',
|
||||
// Pre-filled into the setup screen so Waters volunteers just tap Continue.
|
||||
suggestedServer: (cfg && cfg.apiBase) || '',
|
||||
mode: mode,
|
||||
// app.js shows the setup screen instead of the sign-in sheet when this is true.
|
||||
needsSetup: isNative && mode !== 'local' && !base,
|
||||
setMode: function (m) { try { localStorage.setItem('checklist:mode', m); } catch (_) {} },
|
||||
setServer: function (url) {
|
||||
try { localStorage.setItem('checklist:server', String(url).replace(/\/+$/, '')); } catch (_) {}
|
||||
},
|
||||
clearServer: function () {
|
||||
try {
|
||||
localStorage.removeItem('checklist:server');
|
||||
localStorage.removeItem('checklist:mode');
|
||||
} catch (_) {}
|
||||
},
|
||||
resolveUrl: function (u) { return u; }
|
||||
};
|
||||
window.CHECKLIST_NATIVE = api;
|
||||
|
||||
if (!isNative) return; // ── website: stop here, change nothing ──
|
||||
|
||||
// ── Local mode ────────────────────────────────────────────────────────────────────
|
||||
// No server anywhere. local-server.js runs the real server.js in this WebView, so fetch
|
||||
// is answered in-process rather than over the network. app.js is untouched by this: it
|
||||
// still calls apiGet('/api/state') and still gets exactly the response the real server
|
||||
// would have produced, because it IS the real server.
|
||||
if (mode === 'local') {
|
||||
api.apiBase = '';
|
||||
setupLocalRouting();
|
||||
return;
|
||||
}
|
||||
|
||||
// No server chosen yet. Deliberately do NOT patch fetch — app.js will show the setup
|
||||
// screen and make no API calls until a server exists, and a half-configured patch would
|
||||
// turn that into a screen of confusing 404s.
|
||||
if (!base) return;
|
||||
|
||||
// Server-absolute paths only: "/api/state" yes, "//cdn/x" no (protocol-relative),
|
||||
// "https://…" no (already absolute), "app.js" no (a bundled asset, must stay local).
|
||||
function isServerPath(u) {
|
||||
return typeof u === 'string' && u.charAt(0) === '/' && u.charAt(1) !== '/';
|
||||
}
|
||||
|
||||
api.resolveUrl = function (u) { return isServerPath(u) ? base + u : u; };
|
||||
|
||||
// ── fetch() ───────────────────────────────────────────────────────────────────────
|
||||
// Everything in app.js goes through apiFetch()/apiGet()/apiPost() plus three raw
|
||||
// fetches, and all of them pass a server-absolute string. Patching here covers the lot
|
||||
// without touching a single call site. Request/URL inputs are handled defensively so a
|
||||
// future call site can't quietly break.
|
||||
var nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
try {
|
||||
if (isServerPath(input)) {
|
||||
input = base + input;
|
||||
} else if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
// A Request's url is already absolutised against the shell origin, so match
|
||||
// on that origin rather than on a leading slash.
|
||||
var abs = input.url;
|
||||
if (abs.indexOf(location.origin + '/') === 0) {
|
||||
input = new Request(base + abs.slice(location.origin.length), input);
|
||||
}
|
||||
} else if (typeof URL !== 'undefined' && input instanceof URL) {
|
||||
if (input.origin === location.origin) input = base + input.pathname + input.search;
|
||||
}
|
||||
} catch (_) { /* never let URL rewriting break the request itself */ }
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
// Media stored by the local server lives in IndexedDB, and an <img src> cannot read that.
|
||||
// Blob URLs bridge the gap: every stored file gets one, resolveUrl hands it out, and new
|
||||
// uploads register theirs as soon as the upload response comes back.
|
||||
// NOTE: assigned inside setupLocalRouting, not out here. The local-mode branch above
|
||||
// returns early, so a `var` initialised at this position would still be undefined by the
|
||||
// time resolveUrl ran — hoisting lifts the declaration, never the assignment.
|
||||
var mediaUrls;
|
||||
|
||||
// A Blob built with no type gets an empty content type, and WebKit can refuse to render
|
||||
// that in an <img>. The stored filename carries the extension, so use it.
|
||||
function mimeFor(p) {
|
||||
var m = /\.([A-Za-z0-9]+)$/.exec(p || '');
|
||||
var e = m ? m[1].toLowerCase() : '';
|
||||
return ({
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
||||
webp: 'image/webp', heic: 'image/heic',
|
||||
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
||||
pdf: 'application/pdf'
|
||||
})[e] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function setupLocalRouting() {
|
||||
mediaUrls = Object.create(null);
|
||||
if (!window.LocalServer) {
|
||||
console.error('[native-bridge] local mode selected but local-server.js is missing');
|
||||
return;
|
||||
}
|
||||
var LS = window.LocalServer;
|
||||
|
||||
api.resolveUrl = function (u) {
|
||||
if (typeof u !== 'string') return u;
|
||||
return mediaUrls[u] || u;
|
||||
};
|
||||
|
||||
// Turn every file already in the virtual filesystem into a blob URL, so photos taken
|
||||
// in a previous session show up immediately on this launch.
|
||||
api.indexLocalMedia = function () {
|
||||
return LS.ready().then(function () {
|
||||
var keys = LS.vfs.keys();
|
||||
return Promise.all(keys.map(function (k) {
|
||||
var web = k.indexOf('/data/uploads/') === 0 ? '/equip-images/' + k.slice(14)
|
||||
: k.indexOf('/data/docs/') === 0 ? '/doc-files/' + k.slice(11) : '';
|
||||
if (!web || mediaUrls[web]) return null;
|
||||
return LS.vfs.raw(k).then(function (content) {
|
||||
if (content == null) return;
|
||||
mediaUrls[web] = URL.createObjectURL(new Blob([content], { type: mimeFor(web) }));
|
||||
}).catch(function () {});
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
var nativeFetchLocal = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
var url = typeof input === 'string' ? input
|
||||
: (input && input.url ? input.url.replace(location.origin, '') : '');
|
||||
var isApi = url.indexOf('/api/') === 0;
|
||||
var isMedia = url.indexOf('/equip-images/') === 0 || url.indexOf('/doc-files/') === 0;
|
||||
if (!isApi && !isMedia) return nativeFetchLocal(input, init);
|
||||
|
||||
var opts = init || {};
|
||||
var body;
|
||||
try { body = opts.body ? JSON.parse(opts.body) : undefined; } catch (_) { body = undefined; }
|
||||
var headers = {};
|
||||
try {
|
||||
var h = opts.headers || {};
|
||||
Object.keys(h).forEach(function (k) { headers[k.toLowerCase()] = h[k]; });
|
||||
} catch (_) {}
|
||||
|
||||
return LS.handle(opts.method || 'GET', url, body, headers).then(function (r) {
|
||||
// Register any file the server just wrote, so the <img> that renders a moment
|
||||
// later already has a blob URL to point at.
|
||||
if (r.body && typeof r.body === 'object' && typeof r.body.path === 'string') {
|
||||
var p = r.body.path;
|
||||
if (!mediaUrls[p]) {
|
||||
var disk = p.indexOf('/equip-images/') === 0 ? '/data/uploads/' + p.slice(14)
|
||||
: p.indexOf('/doc-files/') === 0 ? '/data/docs/' + p.slice(11) : '';
|
||||
if (disk) {
|
||||
return LS.vfs.raw(disk).then(function (c) {
|
||||
if (c != null) mediaUrls[p] = URL.createObjectURL(new Blob([c], { type: mimeFor(p) }));
|
||||
return toResponse(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return toResponse(r);
|
||||
});
|
||||
};
|
||||
|
||||
function toResponse(r) {
|
||||
var isJson = r.body !== null && typeof r.body === 'object' && !r.isBuffer;
|
||||
var payload = r.isBuffer ? r.body : (isJson ? JSON.stringify(r.body) : String(r.body == null ? '' : r.body));
|
||||
return new Response(payload, {
|
||||
status: r.status,
|
||||
headers: Object.assign({ 'Content-Type': isJson ? 'application/json' : 'application/octet-stream' }, r.headers || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Haptics ───────────────────────────────────────────────────────────────────────
|
||||
// app.js already calls navigator.vibrate() on check-off. iOS has no Vibration API at
|
||||
// all (it threw benign console errors), and inside a WebView Android's is unreliable.
|
||||
// Routing it to the native Haptics plugin makes check-offs feel right on both, with
|
||||
// zero changes in app.js. Durations map onto the three native impact weights.
|
||||
var plugins = window.CapPlugins || {};
|
||||
if (plugins.Haptics) {
|
||||
navigator.vibrate = function (pattern) {
|
||||
try {
|
||||
var ms = Array.isArray(pattern) ? pattern[0] : pattern;
|
||||
ms = Number(ms) || 0;
|
||||
if (ms <= 0) return true;
|
||||
var style = ms <= 15 ? 'LIGHT' : (ms <= 40 ? 'MEDIUM' : 'HEAVY');
|
||||
plugins.Haptics.impact({ style: style });
|
||||
} catch (_) {}
|
||||
return true; // the Vibration API contract: report success synchronously
|
||||
};
|
||||
}
|
||||
|
||||
// ── External links ────────────────────────────────────────────────────────────────
|
||||
// Attached documents and pasted URLs render as target="_blank". In a WebView that
|
||||
// either does nothing or replaces the app's own page with no way back — both look like
|
||||
// a crash. Hand them to the system browser instead (in-app Safari/Custom Tab), which
|
||||
// is also what App Review expects. Capture phase so we win before other handlers.
|
||||
document.addEventListener('click', function (ev) {
|
||||
var a = ev.target && ev.target.closest && ev.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (!href || href.charAt(0) === '#') return;
|
||||
|
||||
var abs = api.resolveUrl(href);
|
||||
if (!/^https?:\/\//i.test(abs)) return; // mailto:, tel:, blob: — leave to the OS
|
||||
if (abs.indexOf(location.origin) === 0) return; // a link inside our own bundle
|
||||
|
||||
ev.preventDefault();
|
||||
if (plugins.Browser) plugins.Browser.open({ url: abs, presentationStyle: 'popover' });
|
||||
else window.open(abs, '_system');
|
||||
}, true);
|
||||
}());
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Privacy Policy — Waters Checklist</title>
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg?v=2">
|
||||
<!-- Deliberately self-contained: system font stack, no webfont, no script, no external
|
||||
asset of any kind. A privacy page that itself phones out to a third party to render is
|
||||
a bad look, and this one has to load for App Review and Play reviewers on any network. -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #14161b; --panel: #1d2026; --line: #2e333d;
|
||||
--text: #eceef2; --muted: #9aa2ad; --accent: #39a8ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: max(32px, env(safe-area-inset-top)) 22px max(48px, env(safe-area-inset-bottom));
|
||||
}
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 20px; margin-bottom: 28px; }
|
||||
.logo { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.logo svg { width: 34px; height: 34px; border-radius: 8px; display: block; }
|
||||
.logo span { font-weight: 600; font-size: 15px; letter-spacing: .01em; }
|
||||
h1 { font-size: 27px; line-height: 1.25; margin: 0 0 8px; letter-spacing: -.02em; }
|
||||
h2 {
|
||||
font-size: 17px; margin: 34px 0 10px; letter-spacing: -.01em;
|
||||
padding-top: 22px; border-top: 1px solid var(--line);
|
||||
}
|
||||
h2:first-of-type { border-top: 0; padding-top: 0; }
|
||||
p, li { color: #d5d9e0; }
|
||||
.muted { color: var(--muted); font-size: 13.5px; }
|
||||
ul { padding-left: 20px; margin: 12px 0; }
|
||||
li { margin: 7px 0; }
|
||||
li strong { color: var(--text); }
|
||||
a { color: var(--accent); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 16px 18px; margin: 18px 0;
|
||||
}
|
||||
.card p:first-child { margin-top: 0; }
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
.lede { font-size: 17px; color: #dfe3e9; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14.5px; }
|
||||
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
code {
|
||||
background: #262a32; padding: 1.5px 5px; border-radius: 4px;
|
||||
font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #ffffff; --panel: #f6f7f9; --line: #e2e5ea; --text: #16181d; --muted: #5c636e; --accent: #0a66c2; }
|
||||
p, li { color: #2c313a; }
|
||||
code { background: #eceef2; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#39a8ff"/><stop offset="1" stop-color="#2ecc71"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#g)"/>
|
||||
<path d="M150 262 L224 338 L366 178" fill="none" stroke="#fff" stroke-width="46"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Waters Checklist</span>
|
||||
</div>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="muted">Last updated 14 September 2026 · Living Waters Church</p>
|
||||
</header>
|
||||
|
||||
<p class="lede">
|
||||
Waters Checklist is a private tool for Living Waters Church volunteers and staff. It tracks
|
||||
the startup, shutdown and service checklists our ministry teams run each week. It is not a
|
||||
consumer product, it is not advertising-supported, and it has no business model that
|
||||
involves your data.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>The short version.</strong> We collect only what the checklists need to work: who
|
||||
ticked off which task and when, plus anything you type or photograph yourself. There is no
|
||||
analytics, no advertising and no tracking of any kind. Nothing is sold or shared. The data
|
||||
lives on a server owned and operated by the church, not on a commercial cloud platform.</p>
|
||||
</div>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<table>
|
||||
<tr><th>Data</th><th>Why</th></tr>
|
||||
<tr>
|
||||
<td><strong>Sign-in details</strong><br><span class="muted">campus, team, password</span></td>
|
||||
<td>Most logins are shared by a whole team rather than tied to a person. Passwords are stored
|
||||
only as a one-way bcrypt hash — we cannot read yours, and neither can anyone with access
|
||||
to the server.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Your name</strong></td>
|
||||
<td>Only if you sign in with an editor account, which asks who you are so changes can be
|
||||
attributed. Shared team logins do not ask for a name.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email addresses</strong></td>
|
||||
<td>Added by an administrator so a team can receive the weekly summary and urgent
|
||||
equipment-failure alerts. Used for nothing else — no newsletters, no marketing.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Checklist activity</strong></td>
|
||||
<td>Which task was completed, the date and time, and which team or person completed it.
|
||||
This is the point of the app.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>What you write</strong></td>
|
||||
<td>Task and note text, feedback, and requests sent between departments.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Photos, video and files</strong></td>
|
||||
<td>Equipment pictures, short clips and documents you attach to a checklist item.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>A short activity log</strong></td>
|
||||
<td>A rolling record of edits — time, who, and a one-line description — so a team can see
|
||||
what changed. It is capped and older entries are discarded automatically.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
<ul>
|
||||
<li><strong>No analytics or tracking.</strong> The app contains no analytics service, no
|
||||
advertising network, no tracking pixel and no third-party software development kit.</li>
|
||||
<li><strong>No physical location.</strong> "Location" here means a campus you pick from a
|
||||
list — Woonsocket, Apollo Beach and so on. The app never requests or receives GPS or any
|
||||
device location.</li>
|
||||
<li><strong>No contacts, health, or financial information.</strong></li>
|
||||
<li><strong>No advertising identifiers</strong>, and no profile is built about you.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Device permissions</h2>
|
||||
<ul>
|
||||
<li><strong>Camera</strong> — requested only at the moment you choose to photograph a piece of
|
||||
equipment or an issue. The picture goes to the church's own server and nowhere else. The
|
||||
camera is never accessed in the background.</li>
|
||||
<li><strong>Photo library</strong> — used only when you pick an existing picture to attach.
|
||||
The app receives the one file you select; it cannot browse your library.</li>
|
||||
<li><strong>On-device storage</strong> — your sign-in token and a few display preferences (which
|
||||
list you had open, how you like the columns) stay on your own device. If you are offline,
|
||||
check-offs are held on the device until the connection returns.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Where the data lives</h2>
|
||||
<p>
|
||||
On a server owned and run by Living Waters Church, on church premises. It is not hosted with a
|
||||
commercial cloud provider, and no outside company administers it. All traffic between the app
|
||||
and the server is encrypted with HTTPS. The church keeps automatic nightly backups so a
|
||||
mistaken deletion can be undone.
|
||||
</p>
|
||||
|
||||
<h2>Other services</h2>
|
||||
<p>Deliberately few. In normal use the app talks only to the church's own server.</p>
|
||||
<ul>
|
||||
<li><strong>Google Fonts</strong> — the app loads its typeface from Google's font service, which
|
||||
means Google receives the request, including your IP address. If the font cannot be
|
||||
fetched, the app falls back to your device's built-in fonts and works normally.</li>
|
||||
<li><strong>Google Drive</strong> — only if somebody pastes a Google Drive link for a picture.
|
||||
The image preview is then fetched from Google. No account information is exchanged.</li>
|
||||
<li><strong>Email delivery</strong> — weekly summaries and urgent alerts are sent through a
|
||||
standard email provider, which necessarily handles the recipient's address and the message.</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
We do not use any other third-party service, and we never send your data to one for
|
||||
advertising, analytics or resale.
|
||||
</p>
|
||||
|
||||
<h2>Who can see your information</h2>
|
||||
<p>
|
||||
Members of your own team, church administrators, and ministry leaders whose oversight covers
|
||||
your campus. Nobody outside the church has access. We do not sell, rent or trade any of it. We
|
||||
would disclose information only if the law required it.
|
||||
</p>
|
||||
|
||||
<h2>Keeping and deleting it</h2>
|
||||
<p>
|
||||
Checklist records are kept while they remain useful for reviewing how services ran. Church
|
||||
administrators can delete lists, photos, documents and accounts at any time, and photos that
|
||||
are no longer attached to anything are removed automatically. To ask what is held about you,
|
||||
to correct it, or to have it deleted, contact us at the address below and we will act on it.
|
||||
</p>
|
||||
|
||||
<h2>Children</h2>
|
||||
<p>
|
||||
The app is built for volunteers and staff serving on ministry teams and is not directed at
|
||||
children. We do not knowingly collect information from anyone under 13.
|
||||
</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>
|
||||
If this policy changes we will update the date at the top of this page. Significant changes
|
||||
will also be mentioned to team leaders directly.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about this policy, or a request about your information:<br>
|
||||
<a href="mailto:media@waterschurch.org">media@waterschurch.org</a>
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
<p class="muted">
|
||||
Waters Checklist · Living Waters Church · <a href="/">Return to the checklist</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
// Service worker.
|
||||
// Goal: keep the app usable on flaky/no network without ever serving stale DATA.
|
||||
// • App SHELL (HTML/CSS/JS/manifest) — network-first, fall back to cache so the
|
||||
// app still loads when offline. Successful fetches refresh the cache.
|
||||
// • API (/api/*) — never cached. The client handles offline itself (it queues
|
||||
// check-offs in localStorage and syncs on reconnect).
|
||||
|
||||
const SW_VERSION = 'v5';
|
||||
const SHELL_CACHE = 'shell-' + SW_VERSION;
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/app.js', '/styles.css', '/md.js', '/manifest.webmanifest',
|
||||
'/vendor/motion.global.js'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
// Per-URL (not atomic addAll): one missing/404 entry must not reject the whole batch and
|
||||
// leave the new shell cache empty — otherwise a hard-offline navigation right after a
|
||||
// version bump would fall through to the offline page. allSettled caches what it can.
|
||||
caches.open(SHELL_CACHE)
|
||||
.then((c) => Promise.allSettled(SHELL.map((u) => c.add(u))))
|
||||
.catch(() => {})
|
||||
);
|
||||
self.skipWaiting(); // activate the new worker on next load
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
const OFFLINE_HTML =
|
||||
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Offline</title><style>body{font-family:-apple-system,Segoe UI,sans-serif;background:#1a1a1a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px;}h1{color:#3498db;margin-bottom:8px;}p{color:#b0b0b0;max-width:380px;}</style></head><body><div><h1>Offline</h1><p>The checklist needs an internet connection the first time. Try again when you\'re back online.</p></div></body></html>';
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // third-party: passthrough
|
||||
if (url.pathname.startsWith('/api/')) return; // never cache API; client handles offline
|
||||
// Full-resolution equipment photos can be several MB each and pile up fast; caching them
|
||||
// in the shell risks blowing the (small, all-or-nothing) iOS cache quota and evicting the
|
||||
// app shell. They already carry an immutable 1-year HTTP cache header, so let the browser's
|
||||
// HTTP cache handle them and keep them out of the shell cache entirely.
|
||||
if (url.pathname.startsWith('/equip-images/')) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const fresh = await fetch(req);
|
||||
// Cache successful shell/static GETs for offline fallback.
|
||||
if (fresh && fresh.ok && fresh.type === 'basic') {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
cache.put(req, fresh.clone());
|
||||
}
|
||||
return fresh;
|
||||
} catch (_) {
|
||||
const cached = await caches.match(req);
|
||||
if (cached) return cached;
|
||||
if (req.mode === 'navigate') {
|
||||
const idx = (await caches.match('/index.html')) || (await caches.match('/'));
|
||||
if (idx) return idx;
|
||||
return new Response(OFFLINE_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
}
|
||||
throw new Error('Offline');
|
||||
}
|
||||
})());
|
||||
});
|
||||
+1
File diff suppressed because one or more lines are too long
+3832
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user