From fcf38056263bff8f36d06987567c42352b528a92 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 13 Oct 2026 15:34:47 -0400 Subject: [PATCH] Waters Boards: self-hosted work management to replace monday.com Boards with groups, items, subitems and 18 column types; table, kanban, calendar, timeline, chart and files views; updates with @mentions, replies and attachments; inbox, My work, search, automations; Google sign-in restricted to company domains; live updates over SSE; a monday.com API importer; phone layout and installable PWA; Docker + Gitea Actions deploy to the NAS. Co-Authored-By: Claude Opus 5.5 --- .claude/launch.json | 6 + .gitea/workflows/deploy.yml | 81 ++ .gitignore | 5 + README.md | 158 ++++ server/.env.example | 25 + server/Dockerfile | 23 + server/docker-compose.yml | 14 + server/package-lock.json | 1188 +++++++++++++++++++++++++++ server/package.json | 25 + server/public/icons/icon-180.png | Bin 0 -> 5011 bytes server/public/icons/icon-192.png | Bin 0 -> 5292 bytes server/public/icons/icon-512.png | Bin 0 -> 16786 bytes server/public/icons/icon.svg | 6 + server/public/index.html | 24 + server/public/js/actions.js | 168 ++++ server/public/js/api.js | 44 + server/public/js/app.js | 138 ++++ server/public/js/automations.js | 110 +++ server/public/js/board.js | 349 ++++++++ server/public/js/boardModals.js | 96 +++ server/public/js/calendar.js | 86 ++ server/public/js/cells.js | 388 +++++++++ server/public/js/chart.js | 81 ++ server/public/js/item.js | 272 ++++++ server/public/js/kanban.js | 52 ++ server/public/js/pages.js | 291 +++++++ server/public/js/sidebar.js | 165 ++++ server/public/js/store.js | 143 ++++ server/public/js/table.js | 312 +++++++ server/public/js/timeline.js | 96 +++ server/public/js/ui.js | 330 ++++++++ server/public/manifest.webmanifest | 16 + server/public/styles.css | 730 ++++++++++++++++ server/public/sw.js | 17 + server/public/vendor/preact-htm.mjs | 1 + server/server.js | 86 ++ server/src/api.js | 857 +++++++++++++++++++ server/src/auth.js | 150 ++++ server/src/automations.js | 109 +++ server/src/db.js | 200 +++++ server/src/importMonday.js | 410 +++++++++ server/src/model.js | 85 ++ server/src/realtime.js | 37 + server/src/templates.js | 69 ++ 44 files changed, 7443 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/docker-compose.yml create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/public/icons/icon-180.png create mode 100644 server/public/icons/icon-192.png create mode 100644 server/public/icons/icon-512.png create mode 100644 server/public/icons/icon.svg create mode 100644 server/public/index.html create mode 100644 server/public/js/actions.js create mode 100644 server/public/js/api.js create mode 100644 server/public/js/app.js create mode 100644 server/public/js/automations.js create mode 100644 server/public/js/board.js create mode 100644 server/public/js/boardModals.js create mode 100644 server/public/js/calendar.js create mode 100644 server/public/js/cells.js create mode 100644 server/public/js/chart.js create mode 100644 server/public/js/item.js create mode 100644 server/public/js/kanban.js create mode 100644 server/public/js/pages.js create mode 100644 server/public/js/sidebar.js create mode 100644 server/public/js/store.js create mode 100644 server/public/js/table.js create mode 100644 server/public/js/timeline.js create mode 100644 server/public/js/ui.js create mode 100644 server/public/manifest.webmanifest create mode 100644 server/public/styles.css create mode 100644 server/public/sw.js create mode 100644 server/public/vendor/preact-htm.mjs create mode 100644 server/server.js create mode 100644 server/src/api.js create mode 100644 server/src/auth.js create mode 100644 server/src/automations.js create mode 100644 server/src/db.js create mode 100644 server/src/importMonday.js create mode 100644 server/src/model.js create mode 100644 server/src/realtime.js create mode 100644 server/src/templates.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..bf73ce4 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,6 @@ +{ + "version": "0.0.1", + "configurations": [ + { "name": "boards", "runtimeExecutable": "node", "runtimeArgs": ["--watch", "server/server.js"], "port": 3100 } + ] +} diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7a88c06 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,81 @@ +# Push to main -> the NAS rebuilds Waters Boards. +# +# Same shape as the checklist app's deploy: runs on the "nas" act_runner (in Docker on the +# NAS itself), copies server/ over the live directory, then recreates the container in place. +# +# ONE-TIME NAS SETUP before the first run (see README.md › Deploy): +# 1. mkdir /volume2/docker/waters-boards and put a filled-in .env there +# 2. add "/volume2/docker/waters-boards" to container.valid_volumes in the act_runner +# config.yaml on the NAS, and restart the runner +# 3. point a reverse-proxy hostname (https) at port 3100 — Google sign-in needs https +# 4. set the repository variable DEPLOY_ENABLED=true (Settings ▸ Actions ▸ Variables). +# Until then this job is skipped, so pushes don't fail before the NAS is ready. + +name: Deploy Waters Boards to NAS + +on: + push: + branches: [main] + paths: + - 'server/**' + - '.gitea/workflows/deploy.yml' + workflow_dispatch: + +jobs: + deploy: + if: ${{ vars.DEPLOY_ENABLED == 'true' }} + runs-on: nas + container: + image: node:22-alpine + volumes: + # Mounted at the SAME path as on the host: docker-compose.yml binds ./data relative to + # the compose file and the daemon resolves that as a host path. (The docker socket is + # already mounted by act_runner — declaring it again breaks the job.) + - /volume2/docker/waters-boards:/volume2/docker/waters-boards + + steps: + - name: Install tools + run: apk add --no-cache docker-cli docker-cli-compose rsync + + - name: Checkout + uses: actions/checkout@v4 + + - name: Check the deploy directory is set up + run: | + set -eu + DEST=/volume2/docker/waters-boards + test -f "$DEST/.env" || { echo "REFUSING: $DEST/.env is missing — see README.md › Deploy"; exit 1; } + + - name: Copy server files over the live directory + run: | + set -eu + # data/ holds the live database, uploads and backups; .env holds the secrets. + # Neither is in git and neither may ever be written here. No --delete. + rsync -a \ + --exclude 'data/' \ + --exclude '.env' \ + --exclude 'node_modules/' \ + server/ /volume2/docker/waters-boards/ + + - name: Verify the live data and credentials survived + run: | + set -eu + test -f /volume2/docker/waters-boards/.env || { echo "ABORT: .env is gone"; exit 1; } + echo ".env intact"; ls -la /volume2/docker/waters-boards/data 2>/dev/null || echo "(no data/ yet — first deploy)" + + - name: Rebuild the container in place + # No 'down' first: an interrupted deploy then leaves the old container serving. + run: docker compose -f /volume2/docker/waters-boards/docker-compose.yml up -d --build --remove-orphans + + - name: Healthcheck + run: | + set -eu + for i in $(seq 1 20); do + S=$(docker inspect --format '{{.State.Health.Status}}' waters-boards 2>/dev/null || echo starting) + [ "$S" = "healthy" ] && { echo "container healthy"; exit 0; } + sleep 3 + done + echo "container did not report healthy in time:" + docker ps --filter name=waters-boards --format '{{.Names}} {{.Status}}' + docker logs --tail 30 waters-boards + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2103a2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +server/data/ +server/.env +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..01826da --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Waters Boards + +A self-hosted work-management app for the Waters Church team: boards, groups, items, statuses, +people, dates, updates and automations. It covers the parts of monday.com the team uses and +runs on the NAS beside the checklist app, so there's no monthly subscription. + +It's one Node server (Express + SQLite) and a front end with no build step. It runs on desktop +browsers and on phones, and a phone can install it to the home screen like an app. + +--- + +## What's in it + +| | | +|---|---| +| **Workspaces & folders** | Several workspaces, nested folders, favorites, a searchable sidebar. Drag boards into folders. | +| **Boards** | Colored, collapsible groups you can reorder. Items and subitems, drag-and-drop rows, multi-select bulk actions (duplicate, set status, move, archive, delete). | +| **Column types** | Status (custom labels and colors, with "done" labels), People, Date (optional time), Timeline, Text, Long text, Numbers (with unit), Dropdown, Tags, Checkbox, Rating, Link, Email, Phone, Files, Creation log, Last updated, Item ID. You can resize, reorder, rename, duplicate and change the type of any column. Group footers show summaries. | +| **Views** | Main table, Kanban (drag between lanes), Calendar (drag to reschedule; agenda list on phones), Timeline/Gantt (drag or resize bars), Chart (bar/pie plus KPIs), Files gallery. | +| **Board tools** | Search, person filter, advanced filters, sort, hide columns, CSV import and export (opens in Excel), duplicate or move boards, activity log. | +| **Items** | A side panel (full screen on phones) with Updates (@mentions, replies, likes, attachments, paste images), an Info tab with every column, Files, Activity history, subitems, move to another board. | +| **Automations** | "When status changes to X / a column changes / an item is created / a date arrives" → "move to group / notify / set status / assign / set date / clear / archive". | +| **People** | Google sign-in limited to company domains. The Inbox collects mentions, assignments, replies and automation alerts, and live presence shows who's online. *My work* lists everything assigned to you by due date. A Team directory and admin tools control roles and access. | +| **Live** | Changes appear on everyone's screen right away (server-sent events), with no refresh. | +| **Migration** | One-click import from monday.com through its API: workspaces, folders, boards, columns, groups, items, subitems, updates, replies and files. | +| **Themes** | Light, dark and night, or follow the system setting. | +| **Safety** | Nightly database snapshots (14 kept), files served only to signed-in users, deactivated people are signed out everywhere. | + +--- + +## Run it locally + +Needs Node **22.13+** (it uses Node's built-in SQLite, so there's nothing native to compile). + +```bash +cd server +npm install +cp .env.example .env # then set DEV_LOGIN=1 for passwordless local sign-in +npm run dev +``` + +Open . With `DEV_LOGIN=1`, "Developer sign-in" accepts any +allowed-domain email. That button is disabled in production. + +Data goes to `server/data/` (`boards.db`, `uploads/`, `backups/`). The folder is gitignored. + +--- + +## Set up Google sign-in (once) + +Google only allows sign-in from **https** addresses (or `localhost`), so the app needs a +hostname behind the NAS reverse proxy, for example `https://boards.watersmedia.org`, set up the +same way as `check.watersmedia.org`. + +1. Go to and sign in with the church Google Workspace admin account. +2. Create a project (e.g. "Waters Boards"). +3. **APIs & Services → OAuth consent screen**: choose **Internal**, so only + `@waterschurch.org` accounts can ever use it. Fill in the app name and support email, then save. +4. **APIs & Services → Credentials → Create credentials → OAuth client ID** + - Application type: **Web application** + - Authorized JavaScript origins: `https://boards.watersmedia.org` (and `http://localhost:3100` for local testing) + - No redirect URIs are needed. +5. Copy the **Client ID** (ends in `.apps.googleusercontent.com`) into `GOOGLE_CLIENT_ID` in `.env`. + +The server checks every Google sign-in itself. It rejects any account outside +`ALLOWED_DOMAINS` and any email Google hasn't verified. + +Set `ADMIN_PASSWORD` in `.env` as a break-glass login for the `ADMIN_EMAILS` accounts, in case +Google sign-in is ever misconfigured. + +--- + +## Deploy to the NAS + +This follows the same pattern as the checklist app: Docker on the NAS, and Gitea Actions +redeploys on every push to `main` that touches `server/`. + +**One-time setup on the NAS** (`10.1.1.5`): + +```bash +mkdir -p /volume2/docker/waters-boards +cp server/.env.example /volume2/docker/waters-boards/.env # then fill it in +``` + +1. In the act_runner `config.yaml` on the NAS, add `/volume2/docker/waters-boards` to + `container.valid_volumes`, then restart the runner. The runner refuses host mounts that + aren't listed there. +2. Add a reverse-proxy rule: `https://boards.watersmedia.org` → `http://localhost:3100`. + Allow WebSocket/streaming headers, because the live updates use a long-lived HTTP response. +3. In the Gitea repo, set **Settings → Actions → Variables → `DEPLOY_ENABLED` = `true`**. Until it's + set, the deploy job is skipped, so pushes made before the NAS was ready don't show as failures. +4. Push to `main`, or run the workflow by hand from the Gitea Actions tab. + +The workflow (`.gitea/workflows/deploy.yml`) never writes `data/` or `.env`. It rebuilds the +container in place without taking it down first, and fails loudly if `.env` is missing. + +**Manual deploy**, without Gitea: copy `server/` (minus `data/`, `.env` and `node_modules/`) to the +NAS folder, then run `docker compose up -d --build` there. + +--- + +## Bring your monday.com data over + +1. Sign in as an admin and open **Admin → Import from monday.com**. +2. In monday.com, click your avatar → **Developers** → **My access tokens** → copy. +3. Paste the token, click **Load boards**, pick boards (all are selected by default), then click **Import**. + +People are matched by email. Anyone who isn't signed in yet gets a placeholder, and their +assignments carry over when they first sign in with Google. You can re-run the import: boards +that were already imported are skipped. The token is used only for that run and is never +stored. + +Subitems get their own columns in monday. Here, they show the parent board's columns, so +subitem values land in the parent column with the same title. Any value without a matching +column is kept as a note in the subitem's updates. Mirror, formula and connect-board columns +come across as their text value. + +--- + +## Installing on phones + +Open the site in Safari (iPhone) or Chrome (Android), then choose **Share → Add to Home +Screen** or **Install app**. It opens full screen with a bottom tab bar (Home, My work, Boards, +Inbox, Search). Pickers open as bottom sheets. + +--- + +## Backups + +- Every night after 2am the app writes `data/backups/boards-YYYY-MM-DD.db` and keeps 14. +- For off-site copies, back up the whole `data/` folder (database + `uploads/`) the same way + the checklist data is backed up. +- To restore, stop the container, copy a snapshot over `data/boards.db`, and start it again. + +--- + +## Layout + +``` +server/ + server.js Express app, admin import routes, nightly backup + src/db.js SQLite schema (node:sqlite) and helpers + src/auth.js Google ID-token sign-in, sessions, admin password, dev login + src/api.js REST API — boards, columns, groups, items, updates, files, views, automations, search + src/model.js Board loading, value formatting, activity log, notifications + src/automations.js Automation engine + daily date trigger + src/realtime.js Server-sent events + src/importMonday.js monday.com GraphQL importer + src/templates.js Board templates and default labels + public/ Front end (Preact + htm, no build step) + js/app.js Shell, routing, sign-in + js/board.js Board header, views, toolbar, filters + js/table.js Main table (and the phone card list) + js/cells.js Column types: cells, pickers, summaries + js/item.js Item panel: updates, info, files, activity + js/kanban.js · calendar.js · timeline.js · chart.js + js/pages.js Home, My work, Inbox, Search, Team, Admin +``` diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..71dd209 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,25 @@ +# Copy to .env next to docker-compose.yml and fill in. Never commit .env. + +PORT=3100 + +# Google sign-in — the OAuth "Web application" client ID from Google Cloud Console. +# See README.md › "Set up Google sign-in". Leave empty to disable Google sign-in. +GOOGLE_CLIENT_ID= + +# Only Google accounts on these domains can sign in (comma-separated). +ALLOWED_DOMAINS=waterschurch.org + +# These people are always admins (comma-separated). The very first person to sign in +# also becomes an admin. +ADMIN_EMAILS=you@waterschurch.org + +# Optional break-glass password for the ADMIN_EMAILS accounts, for when Google sign-in +# is not set up yet or is broken. Use something long. Leave empty to disable. +ADMIN_PASSWORD= + +# Name of the first workspace created on an empty database. +ORG_NAME=Waters Church + +# Local development only: passwordless sign-in for any allowed-domain email. +# Ignored when NODE_ENV=production (which the Docker image sets). +# DEV_LOGIN=1 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c4d7459 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY server.js ./ +COPY src ./src +COPY public ./public + +# Database, uploads and nightly backups live here; mount a volume in compose. +RUN mkdir -p /app/data +ENV DATA_DIR=/app/data +ENV NODE_ENV=production +ENV PORT=3100 + +EXPOSE 3100 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/healthz >/dev/null 2>&1 || exit 1 + +CMD ["node", "--disable-warning=ExperimentalWarning", "server.js"] diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..0979516 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,14 @@ +services: + boards: + build: . + image: waters-boards:latest + container_name: waters-boards + restart: unless-stopped + ports: + # Host port : container port. 3000 is the checklist app, so this uses 3100. + - "3100:3100" + env_file: + - .env + volumes: + # Database (boards.db), uploaded files and nightly backups. + - ./data:/app/data diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..ace9efa --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1188 @@ +{ + "name": "waters-boards", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "waters-boards", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^16.6.1", + "express": "^4.22.3", + "google-auth-library": "^11.3.0", + "multer": "^2.4.0" + }, + "devDependencies": { + "htm": "^3.1.1" + }, + "engines": { + "node": ">=22.13" + } + }, + "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/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "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.16.0", + "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/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "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.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "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/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "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/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.2.0.tgz", + "integrity": "sha512-eV9lxydJ1sLtP0Kpkd/7RNHuD8ZXL16nqaa4MI4tUargdSBzlkCnZ33ykSQddJFe/hdc4Tmo5tUP31szLDct3Q==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "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/google-auth-library": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.3.0.tgz", + "integrity": "sha512-gZCyszARHj47WNcKrAcQtimDwtTZpydRZ3HSeb66hObfQU9uaG50ejsZ8LzGISIVAjpsZEouGVs65Q/XRau0Qg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.1.0.tgz", + "integrity": "sha512-1R6uiq6XhXW2ZrcGtZpGgVDsYGzSmveVOn8Pp37qe1fbThHZ34e3t3eXj5mVu6PFSVx9fvB1KNVnYgVtq/BRcA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htm": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", + "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", + "dev": true, + "license": "Apache-2.0" + }, + "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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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/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/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "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/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "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/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" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..35f979b --- /dev/null +++ b/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "waters-boards", + "version": "1.0.0", + "description": "Self-hosted boards, tasks and projects for the Waters Church team", + "main": "server.js", + "scripts": { + "start": "node --disable-warning=ExperimentalWarning server.js", + "dev": "node --disable-warning=ExperimentalWarning --watch server.js" + }, + "license": "UNLICENSED", + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^16.6.1", + "express": "^4.22.3", + "google-auth-library": "^11.3.0", + "multer": "^2.4.0" + }, + "devDependencies": { + "htm": "^3.1.1" + }, + "private": true, + "engines": { + "node": ">=22.13" + } +} diff --git a/server/public/icons/icon-180.png b/server/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..347f8d39a8437b536a93ed14dafe3b82cc340ee3 GIT binary patch literal 5011 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 Q*uEhD_4pYp9iT? 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 literal 0 HcmV?d00001 diff --git a/server/public/icons/icon-512.png b/server/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..9b95ae30b2fc22ec6b35f5d1ac70da0e05600b8b GIT binary patch literal 16786 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 + + + + + diff --git a/server/public/index.html b/server/public/index.html new file mode 100644 index 0000000..1c934a7 --- /dev/null +++ b/server/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + Waters Boards + + + + + + + + + +
+ + + diff --git a/server/public/js/actions.js b/server/public/js/actions.js new file mode 100644 index 0000000..80d2ec2 --- /dev/null +++ b/server/public/js/actions.js @@ -0,0 +1,168 @@ +// Board/item mutations: optimistic local update, then the server call. +import { api } from './api.js'; +import { state, mutateBoard, serverCall, loadBoard, loadTree, navigate, toast, boardPath } from './store.js'; +import { confirmDialog, promptDialog } from './ui.js'; + +const B = (boardId) => state.boardData[boardId]; + +export function setValue(boardId, itemId, colId, value) { + mutateBoard(boardId, (b) => { + const it = b.items.find((i) => i.id === itemId); + if (!it) return; + if (value === null || value === '' || (Array.isArray(value) && !value.length)) delete it.vals[colId]; + else it.vals[colId] = value; + it.updated_at = Date.now(); + }); + return serverCall(boardId, api.patch(`/api/items/${itemId}`, { vals: { [colId]: value } })); +} + +export function renameItem(boardId, itemId, name) { + if (!name.trim()) return; + mutateBoard(boardId, (b) => { const it = b.items.find((i) => i.id === itemId); if (it) it.name = name; }); + return serverCall(boardId, api.patch(`/api/items/${itemId}`, { name })); +} + +export async function createItem(boardId, { group_id, parent_id, name, vals = {}, top } = {}) { + const b = B(boardId); + const tmp = { id: 'tmp' + Math.random(), board_id: boardId, group_id: parent_id ? null : group_id, parent_id: parent_id || null, name, vals, position: top ? -1e9 : 1e9, created_at: Date.now(), updated_at: Date.now() }; + if (b) { b.items.push(tmp); } + mutateBoard(boardId, () => {}); + try { + const it = await api.post(`/api/boards/${boardId}/items`, { group_id, parent_id, name, vals, top }); + mutateBoard(boardId, (bd) => { const i = bd.items.findIndex((x) => x.id === tmp.id); if (i >= 0) bd.items[i] = it; else bd.items.push(it); }); + return it; + } catch (e) { + toast(e.message, 'error'); loadBoard(boardId); throw e; + } +} + +export async function deleteItems(boardId, ids, { ask = true } = {}) { + if (ask && !(await confirmDialog({ title: ids.length > 1 ? `Delete ${ids.length} items?` : 'Delete this item?', body: 'Its subitems, updates and files are deleted too. This cannot be undone.' }))) return false; + mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); }); + if (state.route.itemId && ids.includes(state.route.itemId)) navigate(boardPath(boardId, { viewId: state.route.viewId })); + await serverCall(boardId, ids.length === 1 ? api.del(`/api/items/${ids[0]}`) : api.post('/api/items/bulk', { ids, action: 'delete' })); + toast(ids.length > 1 ? `${ids.length} items deleted` : 'Item deleted'); + return true; +} + +export async function archiveItems(boardId, ids) { + mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); }); + await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'archive' })); + toast(ids.length > 1 ? `${ids.length} items archived` : 'Item archived'); +} + +export async function duplicateItems(boardId, ids) { + await serverCall(boardId, ids.length === 1 ? api.post(`/api/items/${ids[0]}/duplicate`) : api.post('/api/items/bulk', { ids, action: 'duplicate' })); + loadBoard(boardId); +} + +export async function moveItem(boardId, itemId, { group_id, position }) { + mutateBoard(boardId, (b) => { + const it = b.items.find((i) => i.id === itemId); + if (it) { if (group_id) it.group_id = group_id; if (position != null) it.position = position; } + }); + return serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id, position })); +} + +export async function moveItemsToGroup(boardId, ids, group_id) { + mutateBoard(boardId, (b) => { for (const it of b.items) if (ids.includes(it.id)) it.group_id = group_id; }); + await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'move', group_id })); +} + +export async function moveItemToBoard(boardId, itemId, targetBoardId) { + const target = state.boardData[targetBoardId] || await loadBoard(targetBoardId); + const g = target.groups[0]; + if (!g) return; + mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => i.id !== itemId && i.parent_id !== itemId); }); + await serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id: g.id })); + toast(`Moved to ${target.board.name}`); + loadBoard(targetBoardId); +} + +// Position between two neighbours (fractional indexing). +export function between(a, b) { + if (a == null && b == null) return 1; + if (a == null) return b - 1; + if (b == null) return a + 1; + return (a + b) / 2; +} + +// ─── Groups ────────────────────────────────────────────── +export async function addGroup(boardId, { top = true } = {}) { + const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/groups`, { title: 'New Group', top })); + await loadBoard(boardId); + return r.id; +} +export function patchGroup(boardId, groupId, patch) { + mutateBoard(boardId, (b) => { const g = b.groups.find((x) => x.id === groupId); if (g) Object.assign(g, patch); }); + return serverCall(boardId, api.patch(`/api/groups/${groupId}`, patch)); +} +export async function deleteGroup(boardId, groupId) { + const b = B(boardId); + const g = b.groups.find((x) => x.id === groupId); + const n = b.items.filter((i) => i.group_id === groupId).length; + if (!(await confirmDialog({ title: `Delete "${g.title}"?`, body: n ? `This deletes the group and its ${n} item${n === 1 ? '' : 's'}.` : 'This group is empty.' }))) return; + mutateBoard(boardId, (bd) => { bd.groups = bd.groups.filter((x) => x.id !== groupId); bd.items = bd.items.filter((i) => i.group_id !== groupId); }); + await serverCall(boardId, api.del(`/api/groups/${groupId}`)); +} + +// ─── Columns ───────────────────────────────────────────── +export async function addColumn(boardId, type, { after, title } = {}) { + const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/columns`, { type, after, title })); + await loadBoard(boardId); + return r.id; +} +export function patchColumn(boardId, colId, patch) { + mutateBoard(boardId, (b) => { const c = b.columns.find((x) => x.id === colId); if (c) Object.assign(c, patch); }); + return serverCall(boardId, api.patch(`/api/columns/${colId}`, patch)); +} +export async function deleteColumn(boardId, colId) { + const c = B(boardId).columns.find((x) => x.id === colId); + if (!(await confirmDialog({ title: `Delete column "${c.title}"?`, body: 'All values in this column are deleted.' }))) return; + mutateBoard(boardId, (b) => { b.columns = b.columns.filter((x) => x.id !== colId); }); + await serverCall(boardId, api.del(`/api/columns/${colId}`)); +} + +// ─── Boards / workspace structure ──────────────────────── +export async function renameBoard(boardId, name) { + mutateBoard(boardId, (b) => { b.board.name = name; }); + const tb = state.boards.find((b) => b.id === boardId); if (tb) tb.name = name; + await serverCall(boardId, api.patch(`/api/boards/${boardId}`, { name })); +} +export async function deleteBoard(boardId) { + const b = state.boards.find((x) => x.id === boardId); + if (!(await confirmDialog({ title: `Delete board "${b?.name}"?`, body: 'Every item, update and file on it is deleted. This cannot be undone.' }))) return; + await api.del(`/api/boards/${boardId}`); + delete state.boardData[boardId]; + if (state.route.boardId === boardId) navigate('/'); + await loadTree(); + toast('Board deleted'); +} +export async function duplicateBoard(boardId) { + const r = await api.post(`/api/boards/${boardId}/duplicate`, {}); + await loadTree(); + navigate(boardPath(r.id)); +} +export async function newFolder(workspace_id, parent_id) { + const name = await promptDialog({ title: 'New folder', placeholder: 'Folder name', confirm: 'Create' }); + if (!name) return; + await api.post('/api/folders', { workspace_id, parent_id, name }); + await loadTree(); +} +export async function renameFolder(f) { + const name = await promptDialog({ title: 'Rename folder', value: f.name }); + if (!name) return; + await api.patch(`/api/folders/${f.id}`, { name }); + await loadTree(); +} +export async function deleteFolder(f) { + if (!(await confirmDialog({ title: `Delete folder "${f.name}"?`, body: 'Boards inside are kept and moved up a level.' }))) return; + await api.del(`/api/folders/${f.id}`); + await loadTree(); +} +export async function toggleFavorite(boardId) { + const on = !state.favorites.includes(boardId); + state.favorites = on ? [...state.favorites, boardId] : state.favorites.filter((x) => x !== boardId); + mutateBoard(boardId, (b) => { b.favorite = on; }); + await api.post(`/api/boards/${boardId}/favorite`, { on }); +} diff --git a/server/public/js/api.js b/server/public/js/api.js new file mode 100644 index 0000000..d580d8d --- /dev/null +++ b/server/public/js/api.js @@ -0,0 +1,44 @@ +// Fetch wrapper + live event stream. +export const clientId = Math.random().toString(36).slice(2); + +export class ApiError extends Error { + constructor(status, message) { super(message); this.status = status; } +} + +async function request(method, url, body, opts = {}) { + const headers = { 'X-Client-Id': clientId }; + let payload = body; + if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string') { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } else if (typeof body === 'string') headers['Content-Type'] = opts.contentType || 'text/plain'; + const res = await fetch(url, { method, headers, body: payload, credentials: 'same-origin' }); + if (res.status === 401 && !url.startsWith('/api/auth')) { + window.dispatchEvent(new CustomEvent('wb:signed-out')); + } + const ct = res.headers.get('content-type') || ''; + const data = ct.includes('json') ? await res.json() : await res.text(); + if (!res.ok) throw new ApiError(res.status, data?.error || res.statusText); + return data; +} + +export const api = { + get: (u) => request('GET', u), + post: (u, b = {}, o) => request('POST', u, b, o), + patch: (u, b = {}) => request('PATCH', u, b), + del: (u) => request('DELETE', u), +}; + +let es = null; +const listeners = new Set(); +export function onEvent(fn) { listeners.add(fn); return () => listeners.delete(fn); } +export function connectEvents() { + if (es) return; + es = new EventSource(`/api/events?client=${clientId}`); + es.onmessage = (m) => { + let ev; try { ev = JSON.parse(m.data); } catch { return; } + for (const fn of listeners) fn(ev); + }; + es.onopen = () => { for (const fn of listeners) fn({ type: 'reconnected' }); }; +} +export function disconnectEvents() { es?.close(); es = null; } diff --git a/server/public/js/app.js b/server/public/js/app.js new file mode 100644 index 0000000..c71912b --- /dev/null +++ b/server/public/js/app.js @@ -0,0 +1,138 @@ +// Entry point: sign-in gate, app shell, routing, overlays, mobile tab bar. +import { html, render, useState, useEffect, useRef } from '../vendor/preact-htm.mjs'; +import { api, connectEvents } from './api.js'; +import { state, update, useStore, navigate, loadBootstrap, applyTheme } from './store.js'; +import { Icon, PopoverHost, ModalHost, Spinner, useMobile } from './ui.js'; +import { Sidebar } from './sidebar.js'; +import { BoardPage } from './board.js'; +import { HomePage, MyWorkPage, InboxPage, SearchPage, PeoplePage, AdminPage, WorkspacePage } from './pages.js'; + +applyTheme(); + +function App() { + const s = useStore(); + const [phase, setPhase] = useState('loading'); // loading | signin | ready + useEffect(() => { + loadBootstrap().then(() => { setPhase('ready'); connectEvents(); }).catch(() => setPhase('signin')); + const out = () => setPhase('signin'); + window.addEventListener('wb:signed-out', out); + return () => window.removeEventListener('wb:signed-out', out); + }, []); + // Keyboard: "/" or Cmd/Ctrl+K opens search + useEffect(() => { + const fn = (e) => { + if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return; + if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); navigate('/search'); } + }; + document.addEventListener('keydown', fn); + return () => document.removeEventListener('keydown', fn); + }, []); + if (phase === 'loading') return html`
`; + if (phase === 'signin') return html`<${SignIn} onDone=${async () => { await loadBootstrap(); connectEvents(); setPhase('ready'); }} />`; + return html`
+ <${Sidebar} /> +
<${Route} />
+ <${MobileTabs} /> + <${PopoverHost} /> + <${ModalHost} /> + <${Toasts} /> +
`; +} + +function Route() { + const r = useStore().route; + useEffect(() => { + const b = r.boardId && state.boards.find((x) => x.id === r.boardId); + document.title = b ? `${b.name} · Waters Boards` : 'Waters Boards'; + }, [r.name, r.boardId, state.boards.length]); + switch (r.name) { + case 'board': return html`<${BoardPage} key=${r.boardId} boardId=${r.boardId} viewId=${r.viewId} itemId=${r.itemId} />`; + case 'my-work': return html`<${MyWorkPage} key=${r.q?.user || 'me'} />`; + case 'inbox': return html`<${InboxPage} />`; + case 'search': return html`<${SearchPage} />`; + case 'people': return html`<${PeoplePage} />`; + case 'admin': return html`<${AdminPage} />`; + case 'workspace': return html`<${WorkspacePage} workspaceId=${r.workspaceId} />`; + default: return html`<${HomePage} />`; + } +} + +function MobileTabs() { + const s = useStore(); + const mobile = useMobile(); + if (!mobile || s.route.itemId) return null; + const r = s.route.name; + const Tab = ({ icon, label, to, active, badge, onClick }) => html``; + return html``; +} + +function Toasts() { + const s = useStore(); + return html`
${s.toasts.map((t) => html`
${t.text} + ${t.action ? html`` : null}
`)}
`; +} + +// ─── Sign in ───────────────────────────────────────────── +function SignIn({ onDone }) { + const [cfg, setCfg] = useState(null); + const [err, setErr] = useState(''); + const [mode, setMode] = useState('google'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const btn = useRef(); + useEffect(() => { api.get('/api/auth/config').then(setCfg).catch(() => setCfg({})); }, []); + useEffect(() => { + if (!cfg?.googleClientId || mode !== 'google') return; + let tries = 0; + const init = () => { + if (!window.google?.accounts?.id) { if (tries++ < 50) setTimeout(init, 100); else setErr("Couldn't load Google sign-in. Check your connection."); return; } + window.google.accounts.id.initialize({ + client_id: cfg.googleClientId, + callback: async (resp) => { + setErr(''); + try { await api.post('/api/auth/google', { credential: resp.credential }); onDone(); } + catch (e) { setErr(e.message); } + }, + hd: cfg.allowedDomains?.length === 1 ? cfg.allowedDomains[0] : undefined, + ux_mode: 'popup', + }); + window.google.accounts.id.renderButton(btn.current, { theme: 'outline', size: 'large', text: 'continue_with', shape: 'pill', width: 280 }); + }; + init(); + }, [cfg, mode]); + const submitPassword = async (e) => { + e.preventDefault(); setErr(''); + try { await api.post(mode === 'dev' ? '/api/auth/dev' : '/api/auth/password', { email, password, name: email.split('@')[0] }); onDone(); } + catch (e2) { setErr(e2.message); } + }; + if (!cfg) return html`
<${Spinner} />
`; + return html`