Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f2393c96 | ||
|
|
cc735aba82 | ||
|
|
385474ecef | ||
|
|
8b80c3fa6f | ||
|
|
ec078116b4 | ||
|
|
2e1b8d24f9 | ||
|
|
9448712a8a | ||
|
|
64c1b3605b | ||
|
|
0b4857c225 | ||
|
|
c6c392730f | ||
|
|
4a3d192c2c | ||
|
|
9b6ea9880e | ||
|
|
a341d67467 | ||
|
|
e62b9601a2 | ||
|
|
3bceb44ae3 | ||
|
|
c130efb180 | ||
|
|
f781930832 | ||
|
|
16d5af83f7 | ||
|
|
38c7d01e03 | ||
|
|
5efaeb63c9 | ||
|
|
eb82263ead | ||
|
|
84ce75dc14 | ||
|
|
2237c199c1 | ||
|
|
0a0356b86e | ||
|
|
84fb396c7d | ||
|
|
df5c4aef48 | ||
|
|
e51bfd3dca | ||
|
|
19fc1e4dcb | ||
|
|
0a160f0800 | ||
|
|
1cc3f72a62 | ||
|
|
7c9e5b0d0d | ||
|
|
45e67c918f | ||
|
|
8d6c471b10 | ||
|
|
1a63da77f3 | ||
|
|
eec422661b | ||
|
|
792714c6d5 | ||
|
|
5a780cefa8 | ||
|
|
306d256832 | ||
|
|
5ef82748ea | ||
|
|
59645e59b5 | ||
|
|
9f0a3dbc74 | ||
|
|
f3d026a109 | ||
|
|
7416898bec | ||
|
|
057d68156b | ||
|
|
99d7e8f121 | ||
|
|
8f82000e31 | ||
|
|
089ee6916f | ||
|
|
122427d30d | ||
|
|
f56dc8efa9 | ||
|
|
22e6e721a7 | ||
|
|
892479dceb | ||
|
|
432dd2176f |
@@ -5,3 +5,4 @@ node_modules
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
README.md
|
||||
appdata/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Generate each with: openssl rand -base64 48
|
||||
POSTGRES_PASSWORD=
|
||||
# One-use secret to create [email protected]; leave blank/remove after bootstrap.
|
||||
BOOTSTRAP_SETUP_TOKEN=
|
||||
# Optional host path mounted read-only at /home/node/.pi/agent (never commit it).
|
||||
PI_AGENT_CONFIG_DIR=./appdata/pi-agent
|
||||
# Optional explicit reverse-proxy IP/CIDR; leave blank if no trusted proxy is present.
|
||||
TRUST_PROXY=
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Test and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: roast-command-center-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-and-deploy:
|
||||
runs-on: unraid
|
||||
steps:
|
||||
- name: Check out the pushed revision
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
# Host-mode action workspaces are ephemeral runner internals. Copy the checked-out SHA
|
||||
# to a normal writable path so Node tests and Docker Compose see the same source tree.
|
||||
- name: Prepare isolated build directory
|
||||
run: |
|
||||
export CI_WORKDIR="/tmp/roast-command-center-${GITHUB_RUN_ID}"
|
||||
rm -rf "$CI_WORKDIR"
|
||||
mkdir -p "$CI_WORKDIR"
|
||||
cp -a ./. "$CI_WORKDIR/"
|
||||
echo "CI_WORKDIR=$CI_WORKDIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd "$CI_WORKDIR" && npm ci
|
||||
|
||||
- name: Test
|
||||
run: cd "$CI_WORKDIR" && npm test
|
||||
|
||||
- name: Deploy to Unraid Docker
|
||||
run: |
|
||||
cd "$CI_WORKDIR"
|
||||
docker compose \
|
||||
--project-name roast_command_center \
|
||||
--env-file /run/roast-production.env \
|
||||
-f docker-compose.yml \
|
||||
up -d --build --remove-orphans
|
||||
|
||||
- name: Verify the running container
|
||||
run: |
|
||||
cd "$CI_WORKDIR"
|
||||
for attempt in {1..15}; do
|
||||
if docker compose \
|
||||
--project-name roast_command_center \
|
||||
--env-file /run/roast-production.env \
|
||||
-f docker-compose.yml \
|
||||
exec -T app node -e "fetch('http://127.0.0.1:8090/').then((response) => process.exit(response.ok ? 0 : 1))"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 15 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
curl --fail --retry 10 --retry-delay 2 https://roast.srmr.xyz/
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
appdata//
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
dev-harness.mjs
|
||||
.scratch/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Development and tests
|
||||
|
||||
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
||||
|
||||
Pushes to `master` automatically run `.gitea/workflows/deploy.yml`: it installs dependencies, runs `npm test`, builds/recreates the Unraid Docker project, and verifies both the container and `https://roast.srmr.xyz/`. Watch each run in **Repository → Actions**; do not report a pushed change complete until its Actions run succeeds. Production runs from `/mnt/user/dev/roast_command_center` on xNAS. The self-hosted Gitea runner is the single `gitea-act-runner` container (registered as `unraid-roast-deployer`); its custom image includes Node, Git, Docker Compose, and curl, and it reads the protected production environment file as `/run/roast-production.env`. Do not create duplicate runners, expose a runner registration token in a command/log/commit, or manually redeploy after a successful pipeline.
|
||||
|
||||
The workflow copies its checked-out revision to an isolated `/tmp` build directory before testing and deploying because the runner's host-executor workspace is ephemeral. `docker-compose.yml` accepts `APP_PORT` (production defaults to `8090`); the container-startup test uses `18090` so CI can run alongside production without binding its port.
|
||||
|
||||
Docker builds run `scripts/stamp-asset-version.mjs`, which replaces `__ASSET_VERSION__` in browser assets and the service-worker cache. Keep those markers intact: each build must produce fresh CSS/JS URLs so Cloudflare cannot serve a mixed stale frontend. The Docker entrypoint copies the read-only `PI_AGENT_CONFIG_DIR` seed mounted at `/run/pi-agent-config` into writable `/home/node/.pi/agent`; Pi locks `auth.json` at runtime, so do not mount that directory directly over the runtime path or prefill will lose configured model credentials.
|
||||
|
||||
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
|
||||
+10
@@ -9,7 +9,17 @@ RUN npm ci --omit=dev
|
||||
COPY public ./public
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
COPY db/migrations ./db/migrations
|
||||
COPY scripts ./scripts
|
||||
RUN node scripts/stamp-asset-version.mjs
|
||||
COPY docker-entrypoint.sh /usr/local/bin/roast-command-center-entrypoint
|
||||
RUN chmod 755 /usr/local/bin/roast-command-center-entrypoint
|
||||
|
||||
# The optional Pi agent configuration is mounted read-only at /run and copied to this writable
|
||||
# runtime directory by the entrypoint; Pi's auth storage needs a sibling lock file.
|
||||
ENV HOME=/home/node
|
||||
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["roast-command-center-entrypoint"]
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
PORT ?= 8090
|
||||
|
||||
.PHONY: all install start dev clean restart
|
||||
|
||||
all: install
|
||||
|
||||
## Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
## Start the server in production mode
|
||||
start:
|
||||
PORT=$(PORT) npm start
|
||||
|
||||
## Start the server in development mode (auto-restart on file changes)
|
||||
dev:
|
||||
PORT=$(PORT) npm run dev
|
||||
|
||||
## Remove node_modules and lockfile
|
||||
clean:
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
## Restart the server
|
||||
restart: clean install start
|
||||
@@ -12,6 +12,37 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
|
||||
- **Reference curve from an Artisan `.alog`** — upload a real roast log (or browse a local
|
||||
library directory) to overlay its actual milestones/curve on the same SVG grid as your
|
||||
plan, dashed and in a different color.
|
||||
- **Finished-roast log with Pi agent review** — upload one or more finished Artisan `.alog`
|
||||
files against a plan (or standalone from `/roasts`); each is stored verbatim (downloadable
|
||||
as a backup), parsed, and deep-reviewed asynchronously by the same zero-tool Pi agent
|
||||
pattern as prefill (grade, highlights, concerns, next-batch suggestions, plan-vs-actual).
|
||||
The `/roasts` page lists every actual roast and renders its curve with the plan overlaid.
|
||||
- **Brewing section** — the app is split into Roasting and Brewing workflows in the nav.
|
||||
`/beans` manages roasted/purchased coffee (with LLM URL prefill from a roaster's product
|
||||
page); `/brews` logs every cup against a bean with a silhouette brewer picker (immersion /
|
||||
percolation / espresso & pressure — 13 methods), recipe fields (dose, water/yield, auto
|
||||
ratio, grind, temp, times), a 0–10 rating, and tasting notes. Bean remaining weight is
|
||||
derived from logged brew doses. Green inventory's lot form also prefills from a URL.
|
||||
- **Plan chat + learned roaster behavior** — an "Ask the LLM" drawer on the planner chats
|
||||
about the open plan, grounded in the computed ledger, the learned pace profile, and a
|
||||
roaster-behavior profile (`/api/roaster-profile`) aggregated from every uploaded .alog
|
||||
(turning-point lag, phase RoR, median milestone temps). The same profile feeds roast
|
||||
reviews and supplies the planner curve's fallback milestone temps.
|
||||
- **API + tokens + Swagger** — every capability (including admin) is a JSON endpoint,
|
||||
documented by a hand-authored OpenAPI 3 spec at `/api/openapi.json` and a self-hosted
|
||||
Swagger UI at `/api-docs`. Users generate bearer tokens (`rpt_…`) on the Account page;
|
||||
token requests skip CSRF (header-borne credentials can't be forged cross-site).
|
||||
- **Backup** — Admin → Backup exports the entire database as one JSON file and can import
|
||||
it back transactionally (delete-and-restore, refuses backups with no active admin, keeps
|
||||
the importing admin's session when possible). Users can download their own data from the
|
||||
Account page.
|
||||
- **Academy** — `/academy` teaches roasting and brewing as narrated, animated slide lessons:
|
||||
green-bean physics through every roast stage (drying, Maillard, first crack, development,
|
||||
curve reading, defects) and extraction plus technique for every brewer family. Narration is
|
||||
Azure TTS (`en-US-Andrew` HD voice), pre-generated to committed MP3s by
|
||||
`scripts/generate-academy-audio.mjs` (reads `AZURE_VOICE_KEY`/`AZURE_VOICE` from `.env`,
|
||||
region eastus — run it locally after editing lesson scripts; production never needs the
|
||||
key). Slides carry display transcripts as captions and phonetic respellings for the voice.
|
||||
- **Live ledger** — the worksheet's time-ledger math (first crack, yellow, Maillard,
|
||||
development, drop, and the four sanity-check ratios) recomputes as you type, using the
|
||||
exact same arithmetic as the paper worksheet (verified against both its worked examples).
|
||||
@@ -21,17 +52,43 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set strong secrets
|
||||
npm install
|
||||
npm start # http://localhost:8090 (set PORT to override)
|
||||
npm test
|
||||
# local PostgreSQL stack (database is not published to the host)
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
The public landing page is at `/`; plans require an account at `/app`. Production is configured for `https://roast.srmr.xyz`: retain `APP_ORIGIN=https://roast.srmr.xyz` and `COOKIE_SECURE=true` behind its HTTPS proxy. Database migrations in `db/migrations/` run at application startup exactly once.
|
||||
|
||||
### First administrator
|
||||
|
||||
Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in the deployment environment, then call `POST /api/auth/bootstrap` with that token, `[email protected]`, and a 12+ character password. The endpoint can create that account only once. Remove the setup token after success; it is optional thereafter and no administrator password is stored in source control.
|
||||
|
||||
### Pi agent configuration in Docker
|
||||
|
||||
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/run/pi-agent-config`. Its entrypoint copies that seed into the non-root Node user's writable runtime configuration directory before startup: Pi's credential storage needs to create a lock beside `auth.json`. This lets `/api/prefill` use the configured model without baking credentials into the image or mutating the host configuration. The directory is ignored by Git and Docker build context; do not commit its contents.
|
||||
|
||||
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p appdata/pi-agent
|
||||
rsync -a --delete ~/.pi/agent/ appdata/pi-agent/
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Set `PI_AGENT_CONFIG_DIR` to another protected host directory instead if preferred. Restrict access to that directory because it can contain provider credentials. The mount is read-only, so Pi cannot alter the host configuration. If deployed behind a reverse proxy, set `TRUST_PROXY` only to that proxy's specific IP/CIDR (or keep it blank when the app is directly exposed).
|
||||
|
||||
## Mobile and PWA use
|
||||
|
||||
The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone.
|
||||
|
||||
Prefill requires a model configured via the `pi` CLI (`~/.pi/agent/{models,auth}.json`) —
|
||||
without one, `/api/prefill` returns `503 no_model` rather than crashing. Everything else
|
||||
(the form, ledger, curve, `.alog` upload) works with no model configured.
|
||||
(the form, ledger, curve, `.alog` upload) works with no model configured. Which configured
|
||||
model runs prefill and roast reviews is chosen in the admin page's **LLM** section
|
||||
(`app_settings.llm_model`, "Auto" = first available); the UI refers to the model backend
|
||||
simply as "the LLM".
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -52,8 +109,7 @@ If those tables change in the paper worksheet, port the change here too.
|
||||
|
||||
## Known gaps (v1)
|
||||
|
||||
- No automated test suite yet (the ledger math and `.alog` parser were verified manually
|
||||
against the worksheet's worked examples and all 14 logs in `ref/roasts/`, respectively).
|
||||
- Offline drafts are intentionally scoped to the authenticated browser account and are cleared on logout; account-backed plans remain the authoritative copy.
|
||||
- Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed,
|
||||
Cloudflare-clearing browser and the operator's own credentials. Use the `.alog` file picker,
|
||||
or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, password_hash text NOT NULL, role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE sessions (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash text NOT NULL, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE roast_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, plan jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE app_settings (key text PRIMARY KEY, value text NOT NULL);
|
||||
INSERT INTO app_settings(key,value) VALUES ('signup_enabled','true');
|
||||
CREATE INDEX roast_plans_user_updated ON roast_plans(user_id, updated_at DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive only: existing users/sessions/roast_plans rows and columns are untouched.
|
||||
CREATE TABLE password_reset_tokens (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
ALTER TABLE users ADD COLUMN disabled_at timestamptz;
|
||||
ALTER TABLE sessions ADD COLUMN user_agent text, ADD COLUMN ip text, ADD COLUMN last_seen_at timestamptz;
|
||||
CREATE TABLE audit_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, action text NOT NULL, target text, created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE INDEX audit_events_created ON audit_events(created_at DESC);
|
||||
CREATE INDEX sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX password_reset_tokens_user_id ON password_reset_tokens(user_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Additive only: existing tables, rows, and columns are untouched.
|
||||
CREATE TABLE green_bean_lots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
origin text NOT NULL,
|
||||
variety text NOT NULL DEFAULT '',
|
||||
process text NOT NULL DEFAULT '',
|
||||
producer text NOT NULL DEFAULT '',
|
||||
purchase_date date,
|
||||
initial_weight_g numeric NOT NULL,
|
||||
remaining_weight_g numeric NOT NULL,
|
||||
cost_total numeric,
|
||||
moisture_pct numeric,
|
||||
density_g_l numeric,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bean_consumption (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
weight_g numeric NOT NULL CHECK (weight_g > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- One draw-down per roast plan, ever (NULLs are distinct in Postgres, so plan-less manual
|
||||
-- entries remain unlimited). This is the server-side backstop that makes the client's
|
||||
-- "Draw from lot" button idempotent even if clicked twice or from two devices.
|
||||
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
|
||||
CREATE TABLE cupping_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL,
|
||||
total_score numeric NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX green_bean_lots_user ON green_bean_lots(user_id, archived, purchase_date DESC);
|
||||
CREATE INDEX bean_consumption_lot ON bean_consumption(lot_id, created_at DESC);
|
||||
CREATE INDEX bean_consumption_user ON bean_consumption(user_id);
|
||||
CREATE INDEX cupping_sessions_user ON cupping_sessions(user_id, updated_at DESC);
|
||||
CREATE INDEX cupping_sessions_plan ON cupping_sessions(roast_plan_id);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Additive only: existing tables, rows, and columns are untouched.
|
||||
-- Finished-roast .alog uploads: the raw file is retained verbatim (original_content) so the
|
||||
-- user can always download their own backup; parsed is the parseAlog() output the UI renders;
|
||||
-- evaluation is the Pi agent's review, written asynchronously after upload.
|
||||
CREATE TABLE actual_roasts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
filename text NOT NULL,
|
||||
original_content text NOT NULL,
|
||||
parsed jsonb NOT NULL,
|
||||
evaluation jsonb,
|
||||
evaluation_status text NOT NULL DEFAULT 'pending',
|
||||
evaluation_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX actual_roasts_user ON actual_roasts(user_id, created_at DESC);
|
||||
CREATE INDEX actual_roasts_plan ON actual_roasts(roast_plan_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. Admin-selectable LLM model ("provider:id") for prefill and roast reviews;
|
||||
-- empty means auto (first available configured model).
|
||||
INSERT INTO app_settings(key,value) VALUES ('llm_model','');
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Additive only. Brewing side of the app: roasted/purchased bean management, brew logs, and
|
||||
-- user-generated API tokens. Bean remaining weight is computed (initial - sum of brew doses),
|
||||
-- never stored, so it can't drift.
|
||||
CREATE TABLE roasted_beans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
roaster text NOT NULL DEFAULT '',
|
||||
origin text NOT NULL DEFAULT '',
|
||||
process text NOT NULL DEFAULT '',
|
||||
variety text NOT NULL DEFAULT '',
|
||||
roast_level text NOT NULL DEFAULT '',
|
||||
roast_date date,
|
||||
initial_weight_g numeric,
|
||||
url text NOT NULL DEFAULT '',
|
||||
tasting_notes text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE brews (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,
|
||||
method text NOT NULL,
|
||||
dose_g numeric,
|
||||
water_g numeric,
|
||||
yield_g numeric,
|
||||
grinder text NOT NULL DEFAULT '',
|
||||
grind_setting text NOT NULL DEFAULT '',
|
||||
water_temp_c numeric,
|
||||
brew_time_s integer,
|
||||
bloom_time_s integer,
|
||||
rating numeric,
|
||||
tasting_notes text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
brewed_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE api_tokens (
|
||||
token_hash text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz
|
||||
);
|
||||
CREATE INDEX roasted_beans_user ON roasted_beans(user_id, archived, created_at DESC);
|
||||
CREATE INDEX brews_user ON brews(user_id, brewed_at DESC);
|
||||
CREATE INDEX brews_bean ON brews(bean_id);
|
||||
CREATE INDEX api_tokens_user ON api_tokens(user_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. Free-text recipe/steps for a brew (bloom, pours, plunge timing...) —
|
||||
-- distinct from tasting_notes (how it tasted) and notes (what to change next time).
|
||||
ALTER TABLE brews ADD COLUMN recipe text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive only. Per-user owned equipment: which brewers the Brews page should offer
|
||||
-- (subset of the shared brew-method keys) and the user's grinders (free-text names).
|
||||
CREATE TABLE user_gear (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
brewers jsonb NOT NULL DEFAULT '[]',
|
||||
grinders jsonb NOT NULL DEFAULT '[]',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Additive only. Per-user roasting machines: uploaded .alogs attach to one, learning is
|
||||
-- computed per roaster, and `overrides` stores the user's manual tweaks to what the learned
|
||||
-- profile would otherwise apply to their plans (milestone temps, TP time, pace factor).
|
||||
CREATE TABLE roasters (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
model text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
overrides jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX roasters_user ON roasters(user_id, created_at);
|
||||
ALTER TABLE actual_roasts ADD COLUMN roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL;
|
||||
CREATE INDEX actual_roasts_roaster ON actual_roasts(roaster_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Additive only. Account profile picture, stored as a small client-side-resized data URL.
|
||||
ALTER TABLE users ADD COLUMN avatar text;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. "After the roast" moves off the plan worksheet onto the actual roast row —
|
||||
-- one system: the uploaded .alog, its review, and the post-roast observations live together.
|
||||
ALTER TABLE actual_roasts ADD COLUMN after jsonb NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Additive only. Custom display names for roast plans and green bean lots, layered on top of
|
||||
-- (not replacing) worksheet field 0.1 "Coffee / lot" — COALESCE(NULLIF(name,''), field 0.1) is
|
||||
-- the fallback everywhere a plan title is shown.
|
||||
ALTER TABLE roast_plans ADD COLUMN name text NOT NULL DEFAULT '';
|
||||
ALTER TABLE green_bean_lots ADD COLUMN name text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://roast:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/roast
|
||||
APP_ORIGIN: https://roast.srmr.xyz
|
||||
COOKIE_SECURE: "true"
|
||||
# Optional after the one-time administrator bootstrap has completed.
|
||||
BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-}
|
||||
# Leave unset unless a known reverse-proxy address/CIDR is configured.
|
||||
TRUST_PROXY: ${TRUST_PROXY:-}
|
||||
# Browser-rendered fallback for storefronts that rate-limit server fetches.
|
||||
BROWSERLESS_URL: ${BROWSERLESS_URL:-http://host.docker.internal:9085}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Seed Pi's writable runtime config from this read-only credential mount.
|
||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/run/pi-agent-config:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
|
||||
ports: ["${APP_PORT:-8090}:8090"]
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: roast
|
||||
POSTGRES_USER: roast
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U roast -d roast"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# The mounted Pi configuration contains credentials and must stay read-only. Pi's auth storage
|
||||
# takes a lock beside auth.json, so seed a writable per-container copy before the app starts.
|
||||
# That lets ModelRuntime read and refresh credentials without ever mutating the host mount.
|
||||
if [ -d /run/pi-agent-config ]; then
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
cp -a /run/pi-agent-config/. "$HOME/.pi/agent/"
|
||||
chmod -R u+rwX "$HOME/.pi/agent"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Generated
+767
-21
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -6,10 +6,19 @@
|
||||
"description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js"
|
||||
"dev": "node --watch server/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.83.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"express": "^5.0.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.82.1"
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0",
|
||||
"swagger-ui-dist": "^5.32.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg-mem": "^3.0.14",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Academy — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Academy</h1>
|
||||
<p class="brand-sub">
|
||||
Roasting and brewing, taught from the bean's point of view
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<div id="academy-menu"></div>
|
||||
|
||||
<section class="panel-card hidden" id="academy-player">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2 id="player-slide-title" style="margin:0"></h2>
|
||||
<span class="muted" id="player-lesson" style="font-size:12.5px"></span>
|
||||
</div>
|
||||
<button class="ghost-btn small" type="button" id="player-close">
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="academy-stage">
|
||||
<svg
|
||||
id="player-scene"
|
||||
viewBox="0 0 560 300"
|
||||
role="img"
|
||||
aria-label="Lesson animation"
|
||||
></svg>
|
||||
</div>
|
||||
<p class="academy-caption" id="player-caption" aria-live="polite"></p>
|
||||
<div class="academy-controls">
|
||||
<button class="ghost-btn small" type="button" id="player-prev">
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
class="ghost-btn small"
|
||||
type="button"
|
||||
id="player-play"
|
||||
aria-label="Pause narration"
|
||||
>
|
||||
❚❚
|
||||
</button>
|
||||
<div class="academy-dots" id="player-dots"></div>
|
||||
<span class="muted" id="player-count"></span>
|
||||
<button class="primary-btn" type="button" id="player-next">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/academy.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,318 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Account — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Account</h1>
|
||||
<p class="brand-sub">Profile, security, plans, and devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="profile">
|
||||
<div class="panel-head">
|
||||
<h2>Profile</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px">
|
||||
<div
|
||||
class="nav-user-avatar"
|
||||
id="profile-avatar"
|
||||
style="width:64px;height:64px;font-size:24px;flex:none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<label class="filebtn ghost-btn small"
|
||||
>Upload picture…<input
|
||||
type="file"
|
||||
id="avatar-file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
/></label>
|
||||
<button
|
||||
class="ghost-btn small hidden"
|
||||
type="button"
|
||||
id="avatar-remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong id="profile-email"></strong>
|
||||
<span class="badge badge-admin hidden" id="profile-role-admin"
|
||||
>Admin</span
|
||||
>
|
||||
</p>
|
||||
<p class="muted" id="profile-since"></p>
|
||||
<p class="subhead">Change email</p>
|
||||
<form id="email-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">New email</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Current password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Update email</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="security">
|
||||
<div class="panel-head">
|
||||
<h2>Security</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="subhead">Change password</p>
|
||||
<form id="password-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">Current password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">New password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required
|
||||
minlength="12"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Confirm new password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required
|
||||
minlength="12"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">
|
||||
Update password
|
||||
</button>
|
||||
</form>
|
||||
<p class="subhead">Active sessions</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Last seen</th>
|
||||
<th>Expires</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="ghost-btn" type="button" id="btn-revoke-others">
|
||||
Sign out everywhere else
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="your-plans">
|
||||
<div class="panel-head">
|
||||
<h2>Your plans</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="plans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="app">
|
||||
<div class="panel-head">
|
||||
<h2>App</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Install Roast Planner for a focused, offline-capable
|
||||
workspace. Updates wait for your confirmation so an
|
||||
in-progress plan is never discarded.
|
||||
</p>
|
||||
<button id="btn-install" type="button" class="primary-btn hidden">
|
||||
Install Roast Planner
|
||||
</button>
|
||||
<button id="btn-refresh" type="button" class="ghost-btn hidden">
|
||||
Update ready — refresh safely
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="api-tokens">
|
||||
<div class="panel-head">
|
||||
<h2>API tokens</h2>
|
||||
<a class="ghost-btn small" href="/api-docs">API docs</a>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Tokens authenticate API requests with an
|
||||
<code>Authorization: Bearer rpt_…</code> header — full access
|
||||
to your account, so treat them like passwords.
|
||||
</p>
|
||||
<form
|
||||
id="token-form"
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
name="name"
|
||||
placeholder="Token name (e.g. shortcuts, cli)"
|
||||
style="max-width:260px"
|
||||
/>
|
||||
<button class="ghost-btn" type="submit" id="token-create">
|
||||
Generate token
|
||||
</button>
|
||||
</form>
|
||||
<div class="hidden" id="token-reveal-wrap" style="margin-top:10px">
|
||||
<p class="field-note" style="margin:0 0 4px">
|
||||
Copy it now — it will never be shown again.
|
||||
</p>
|
||||
<div class="token-reveal" id="token-reveal"></div>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:12px">
|
||||
<table class="data-table" id="tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokens-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="my-data">
|
||||
<div class="panel-head"><h2>Your data</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Download everything you've put into Roast Planner — plans,
|
||||
roasts, inventory, cupping, beans, and brews — as one JSON
|
||||
file.
|
||||
</p>
|
||||
<a class="ghost-btn" href="/api/account/export" download
|
||||
>Download my data</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card danger-zone" id="danger">
|
||||
<div class="panel-head">
|
||||
<h2>Danger zone</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Deleting your account permanently removes your plans and
|
||||
cannot be undone.
|
||||
</p>
|
||||
<form id="delete-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label"
|
||||
>Type your email to confirm</span
|
||||
><input class="field-input" type="email" name="confirmEmail" required
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<button class="ghost-btn" type="submit" style="border-color:var(--fail);color:var(--fail)">
|
||||
Delete my account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/account.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Admin — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Administration</h1>
|
||||
<p class="brand-sub">Users, plans, and app settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content wide">
|
||||
<section class="stat-grid" id="metrics" aria-label="Metrics"></section>
|
||||
|
||||
<section class="panel-card" id="reset-links-card" hidden>
|
||||
<div class="panel-head"><h2>Pending password resets</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
No outgoing email is configured for this deployment
|
||||
(<code>SMTP_URL</code>). Hand these links to the user
|
||||
directly.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="resets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Link</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resets-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-head"><h2>Signups</h2></div>
|
||||
<div class="panel-body">
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="signup-toggle" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Allow new signups</label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="llm-card">
|
||||
<div class="panel-head"><h2>LLM</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="margin-top:0">
|
||||
Model used for URL prefill and finished-roast reviews.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<select
|
||||
class="text-input"
|
||||
id="llm-model-select"
|
||||
style="max-width:360px"
|
||||
aria-label="LLM model"
|
||||
></select>
|
||||
<button class="ghost-btn small" type="button" id="llm-model-save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note" id="llm-model-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="backup-card">
|
||||
<div class="panel-head"><h2>Backup</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Export downloads the <strong>entire database</strong> (all
|
||||
users and their data) as one JSON file. Import
|
||||
<strong>replaces everything</strong> with a previously
|
||||
exported file.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<a class="ghost-btn" href="/api/admin/backup" download
|
||||
>Export full backup</a
|
||||
>
|
||||
<label class="filebtn ghost-btn"
|
||||
>Import backup…<input
|
||||
type="file"
|
||||
id="backup-import-file"
|
||||
accept="application/json,.json"
|
||||
/></label>
|
||||
</div>
|
||||
<p class="field-note" id="backup-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="users">
|
||||
<div class="panel-head">
|
||||
<h2>Users</h2>
|
||||
<input
|
||||
class="text-input"
|
||||
id="user-search"
|
||||
placeholder="Search by email…"
|
||||
style="max-width:220px"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Plans</th>
|
||||
<th>Joined</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="plans">
|
||||
<div class="panel-head">
|
||||
<h2>Roast plans</h2>
|
||||
<button class="ghost-btn small hidden" id="clear-plan-filter">
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="plans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Title</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="activity">
|
||||
<div class="panel-head"><h2>Recent activity</h2></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/admin.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>API docs — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/swagger/swagger-ui.css" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.rp-docs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #1b1614;
|
||||
color: #fbf8f4;
|
||||
font:
|
||||
14px -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
.rp-docs-bar a {
|
||||
color: #f3b28c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rp-docs-bar">
|
||||
<strong>Roast Planner API</strong>
|
||||
<span
|
||||
>Authenticate with your session (already active in this browser) or an
|
||||
API token from your <a href="/account#api-tokens">account page</a
|
||||
>.</span
|
||||
>
|
||||
<a href="/app" style="margin-left:auto">← Back to the app</a>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/swagger/swagger-ui-bundle.js"></script>
|
||||
<script src="/js/swagger-init.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3277
-368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Beans — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Beans</h1>
|
||||
<p class="brand-sub">Roasted or purchased coffee ready to brew</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="beans">
|
||||
<div class="panel-head">
|
||||
<h2>Your beans</h2>
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="show-archived" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Show archived</label
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="beans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bean</th>
|
||||
<th>Origin / process</th>
|
||||
<th>Roasted</th>
|
||||
<th>Remaining</th>
|
||||
<th class="num">Brews</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="beans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="bean-form-card">
|
||||
<div class="panel-head"><h2 id="bean-form-title">Add a bean</h2></div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="bean-prefill-url"
|
||||
type="url"
|
||||
placeholder="Paste the roaster's product URL to prefill…"
|
||||
style="flex:1;min-width:220px"
|
||||
/>
|
||||
<button class="ghost-btn" type="button" id="bean-prefill-btn">
|
||||
Prefill from URL
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note hidden" id="bean-prefill-note"></p>
|
||||
<form id="bean-form">
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Name *</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="name"
|
||||
required
|
||||
placeholder="e.g. Kiamabara AB"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roaster</span
|
||||
><input class="field-input" name="roaster" placeholder="or “home roast”"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Origin</span
|
||||
><input class="field-input" name="origin"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Process</span
|
||||
><input class="field-input" name="process"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Variety</span
|
||||
><input class="field-input" name="variety"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roast level</span
|
||||
><input class="field-input" name="roastLevel" placeholder="light / medium / dark"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roast date</span
|
||||
><input class="field-input" type="date" name="roastDate"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Bag weight</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="initialWeightG"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Roaster's tasting notes</span
|
||||
><input class="field-input" name="tastingNotes" placeholder="e.g. blackcurrant, cocoa"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Product URL</span
|
||||
><input class="field-input" name="url" type="url"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes</span
|
||||
><input class="field-input" name="notes"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit" id="bean-form-submit">
|
||||
Add bean
|
||||
</button>
|
||||
<button class="ghost-btn hidden" type="button" id="bean-form-cancel">
|
||||
Cancel edit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/beans.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="110 78 292 356">
|
||||
<!-- Standalone Roast Planner bean mark (no tile) for nav / auth / marketing lockups. -->
|
||||
<defs>
|
||||
<clipPath id="bean-clip">
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g transform="rotate(-20 256 256)">
|
||||
<g clip-path="url(#bean-clip)">
|
||||
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
|
||||
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
|
||||
</g>
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
|
||||
fill="none" stroke="#54371F" stroke-width="14"/>
|
||||
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 900 B |
@@ -0,0 +1,203 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Brews — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Brews</h1>
|
||||
<p class="brand-sub">Log every cup — method, recipe, and how it tasted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="brew-form-card">
|
||||
<div class="panel-head">
|
||||
<h2 id="brew-form-title">Log a brew</h2>
|
||||
<button class="ghost-btn small hidden" type="button" id="brew-form-cancel">
|
||||
Cancel edit
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="brew-form">
|
||||
<label class="field" style="max-width:420px"
|
||||
><span class="field-label">Bean</span>
|
||||
<select class="field-input" name="beanId" id="brew-bean-select">
|
||||
<option value="">— no bean selected —</option>
|
||||
</select></label
|
||||
>
|
||||
<div id="brewer-picker" style="margin:12px 0"></div>
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Dose</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="doseG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field" data-espresso-hide
|
||||
><span class="field-label">Water</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="waterG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field hidden" data-espresso-show
|
||||
><span class="field-label">Yield (out)</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="yieldG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Ratio</span
|
||||
><input class="field-input" id="brew-ratio" readonly placeholder="—"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Water temp</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="waterTempC" inputmode="decimal" min="0" max="100" step="any" /><span class="unit">°C</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Grinder</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="grinder"
|
||||
list="grinder-options"
|
||||
placeholder="e.g. Comandante C40"
|
||||
/><datalist id="grinder-options"></datalist></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Grind setting</span
|
||||
><input class="field-input" name="grindSetting" placeholder="e.g. 22 clicks"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Total time</span
|
||||
><input class="field-input" name="brewTime" placeholder="m:ss"
|
||||
/></label>
|
||||
<label class="field" data-espresso-hide
|
||||
><span class="field-label">Bloom</span
|
||||
><input class="field-input" name="bloomTime" placeholder="m:ss"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Recipe / steps</span
|
||||
><textarea
|
||||
class="field-input"
|
||||
name="recipe"
|
||||
rows="3"
|
||||
placeholder="e.g. 45 g bloom, swirl · 0:45 pour to 150 g · 1:30 pour to 250 g · drawdown by 2:45"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="field" style="margin-top:6px"
|
||||
><span class="field-label"
|
||||
>Rating <span class="rating-pill" id="rating-value">–</span></span
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
name="rating"
|
||||
id="brew-rating"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value="5"
|
||||
style="width:min(360px,100%)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field"
|
||||
><span class="field-label">Tasting notes</span
|
||||
><textarea
|
||||
class="field-input"
|
||||
name="tastingNotes"
|
||||
rows="2"
|
||||
placeholder="What you actually tasted — sweetness, acidity, body, finish…"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes / next time</span
|
||||
><input class="field-input" name="notes" placeholder="e.g. grind finer next time"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit" id="brew-form-submit">
|
||||
Save brew
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="brew-history">
|
||||
<div class="panel-head">
|
||||
<h2>Brew history</h2>
|
||||
<button class="ghost-btn small hidden" id="clear-bean-filter">
|
||||
Clear bean filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="brews-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Method</th>
|
||||
<th>Bean</th>
|
||||
<th>Recipe</th>
|
||||
<th>Rating</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="brews-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/brews.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,250 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Cupping — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1 id="cupping-title">Cupping</h1>
|
||||
<p class="brand-sub" id="cupping-subtitle">
|
||||
Tasting and scoring sessions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions hidden" id="session-autosave-wrap">
|
||||
<span class="autosave-chip" id="cupping-autosave-status"
|
||||
><span class="dot"></span
|
||||
><span class="autosave-text">Not saved yet</span></span
|
||||
>
|
||||
<button id="btn-expand-why" type="button" class="ghost-btn">
|
||||
Expand all "why" notes
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content wide" id="cupping-list-view">
|
||||
<section class="panel-card" id="cupping-sessions-list">
|
||||
<div class="panel-head"><h2>Cupping sessions</h2></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Coffee</th>
|
||||
<th class="num">Score</th>
|
||||
<th class="num">Cups</th>
|
||||
<th>Flavors</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="new-session-card">
|
||||
<div class="panel-head"><h2>New session</h2></div>
|
||||
<div class="panel-body">
|
||||
<form id="new-session-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">Roast (optional)</span>
|
||||
<select class="field-input" id="new-session-plan">
|
||||
<option value="">— no linked roast —</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Cups</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="number"
|
||||
id="new-session-cups"
|
||||
min="1"
|
||||
max="12"
|
||||
value="5"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">
|
||||
Start cupping
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="page-content wide hidden" id="cupping-session-view">
|
||||
<div class="workspace-grid">
|
||||
<div class="form-col">
|
||||
<section class="panel-card" id="cup-scores">
|
||||
<div class="panel-head">
|
||||
<h2>Scores</h2>
|
||||
<label class="field" style="margin:0">
|
||||
<span class="field-label">Cups</span>
|
||||
<input
|
||||
class="field-input sm"
|
||||
type="number"
|
||||
id="cup-count"
|
||||
min="1"
|
||||
max="12"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-scores">
|
||||
<summary>Why the scale starts at 6</summary>
|
||||
<p>
|
||||
The 6–10 range is a specialty-grading convention:
|
||||
anything below specialty grade simply isn't scored, so
|
||||
6 is the floor of the scale, not "zero". Score in
|
||||
quarter-point steps and leave an attribute unscored
|
||||
(—) rather than guessing — an unscored attribute
|
||||
contributes nothing, which is honest; a guessed 7.5
|
||||
pollutes every comparison you make later. The tick
|
||||
rows count cups, not quality: five clean cups out of
|
||||
five is full marks even if the coffee is merely good.
|
||||
</p>
|
||||
</details>
|
||||
<div id="cup-score-rows"></div>
|
||||
<div id="cup-tick-rows"></div>
|
||||
<details class="cupping-defects">
|
||||
<summary>Defects</summary>
|
||||
<div id="cup-defect-rows"></div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="cup-flavors">
|
||||
<div class="panel-head"><h2>Flavors</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-flavors">
|
||||
<summary>Tag what you can point at</summary>
|
||||
<p>
|
||||
A tag is only useful if you could defend it to
|
||||
another taster with the cup in front of you. Two or
|
||||
three confident descriptors beat a dozen hopeful ones
|
||||
— the list is capped at 32, but a session that needs
|
||||
10 is already suspect.
|
||||
</p>
|
||||
</details>
|
||||
<p class="field-note hidden" id="flavor-limit-note">
|
||||
32-tag limit reached.
|
||||
</p>
|
||||
<div class="flavor-wheel-wrap">
|
||||
<svg
|
||||
id="flavor-wheel"
|
||||
role="group"
|
||||
aria-label="Flavor wheel — tap a wedge to add or remove a flavor"
|
||||
></svg>
|
||||
</div>
|
||||
<div id="flavor-families"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="cup-notes">
|
||||
<div class="panel-head"><h2>Notes</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-notes">
|
||||
<summary>Notes outlive scores</summary>
|
||||
<p>
|
||||
Numbers compare roasts; sentences explain them. Write
|
||||
what you'd want to read before roasting this coffee
|
||||
again — texture, where it fell apart as it cooled,
|
||||
what you'd change.
|
||||
</p>
|
||||
</details>
|
||||
<textarea
|
||||
class="field-input"
|
||||
id="cup-notes-text"
|
||||
maxlength="4000"
|
||||
rows="5"
|
||||
aria-label="Cupping notes"
|
||||
></textarea>
|
||||
<p class="field-note"><span id="cup-notes-count">0</span>/4000</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="dock">
|
||||
<div class="dock-card">
|
||||
<div class="dock-card-head"><h2>Score</h2></div>
|
||||
<div class="stat-tile" style="box-shadow:none;border:none;padding:0">
|
||||
<div class="stat-tile-label">Total score</div>
|
||||
<div class="stat-tile-value" id="cup-total">0.00</div>
|
||||
</div>
|
||||
<p class="dock-note">Recomputed by the server on every save.</p>
|
||||
<div class="chip-group" id="flavor-chips"></div>
|
||||
</div>
|
||||
<div class="dock-card">
|
||||
<div class="dock-card-head"><h2>Radar</h2></div>
|
||||
<svg
|
||||
id="cup-radar"
|
||||
viewBox="0 0 240 240"
|
||||
role="img"
|
||||
aria-label="Cupping score radar chart"
|
||||
></svg>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Runs synchronously at parse time (before the deferred module script below) so opening
|
||||
// /cupping?session=… never flashes the empty sessions list first while cupping.js's own
|
||||
// async init (which awaits the auth check) is still getting underway.
|
||||
if (new URLSearchParams(location.search).get("session")) {
|
||||
document.getElementById("cupping-list-view").classList.add("hidden");
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/js/cupping.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Reset your password — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Forgot your password?</h1>
|
||||
<p class="lede">
|
||||
Enter your account email and we'll send a link to reset your
|
||||
password.
|
||||
</p>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="forgot-form">
|
||||
<label
|
||||
>Email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Send reset link</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/login">Back to log in</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/forgot.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Equipment — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Equipment</h1>
|
||||
<p class="brand-sub">
|
||||
The brewers and grinders you own — Brews only offers these
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="brewers-card">
|
||||
<div class="panel-head">
|
||||
<h2>Your brewers</h2>
|
||||
<span class="muted" id="brewers-count"></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="field-note" style="margin-top:0">
|
||||
Tap the brewers you own. With none selected, the Brews page
|
||||
shows every method.
|
||||
</p>
|
||||
<div id="gear-brewer-picker"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="grinders-card">
|
||||
<div class="panel-head"><h2>Your grinders</h2></div>
|
||||
<div class="panel-body">
|
||||
<form
|
||||
id="grinder-form"
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="grinder-name"
|
||||
placeholder="e.g. Comandante C40, DF64"
|
||||
style="max-width:280px"
|
||||
/>
|
||||
<button class="ghost-btn" type="submit">Add grinder</button>
|
||||
</form>
|
||||
<ul class="lib-list" id="grinder-list" style="margin-top:10px"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/gear.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+17
-5
@@ -1,7 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#FBF8F4"/>
|
||||
<circle cx="256" cy="256" r="176" fill="#A8481A"/>
|
||||
<path fill="#FBF8F4" d="M166 166h180v45h-135v90h116v45H211v-90h-45z"/>
|
||||
<path fill="#FBEFE6" d="M256 118c43 54 71 91 71 133a71 71 0 1 1-142 0c0-42 28-79 71-133Z" opacity=".9"/>
|
||||
<path fill="#A8481A" d="M233 189c-13 22-20 42-20 60a43 43 0 1 0 86 0c0-18-7-38-20-60 3 10 4 19 4 28 0 23-12 42-27 42s-27-19-27-42c0-9 1-18 4-28Z"/>
|
||||
<!-- Roast Planner mark: one bean, raw → roasted. Green half is the lot on the shelf,
|
||||
ember half is the finished roast; the crease is the journey between them. -->
|
||||
<defs>
|
||||
<clipPath id="bean-clip">
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="120" fill="#FBF8F4"/>
|
||||
<g transform="rotate(-20 256 256)">
|
||||
<g clip-path="url(#bean-clip)">
|
||||
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
|
||||
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
|
||||
</g>
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
|
||||
fill="none" stroke="#54371F" stroke-width="14"/>
|
||||
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 1.0 KiB |
+2479
-740
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inventory — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Inventory</h1>
|
||||
<p class="brand-sub">Green bean lots and remaining stock</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="lots">
|
||||
<div class="panel-head">
|
||||
<h2>Green bean lots</h2>
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="show-archived" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Show archived</label
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="lots-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lot</th>
|
||||
<th>Process</th>
|
||||
<th>Remaining</th>
|
||||
<th>Purchased</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lots-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="lot-form-card">
|
||||
<div class="panel-head"><h2 id="lot-form-title">Add a lot</h2></div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="lot-prefill-url"
|
||||
type="url"
|
||||
placeholder="Paste the seller's product URL to prefill…"
|
||||
style="flex:1;min-width:220px"
|
||||
/>
|
||||
<button class="ghost-btn" type="button" id="lot-prefill-btn">
|
||||
Prefill from URL
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note hidden" id="lot-prefill-note"></p>
|
||||
<form id="lot-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Name</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="name"
|
||||
placeholder="e.g. Spring Ethiopia — defaults to origin"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Origin *</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="origin"
|
||||
required
|
||||
placeholder="e.g. Huila, Colombia"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Variety</span
|
||||
><input class="field-input" name="variety"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Process</span
|
||||
><input class="field-input" name="process"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Producer</span
|
||||
><input class="field-input" name="producer"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Purchase date</span
|
||||
><input class="field-input" type="date" name="purchaseDate"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Initial weight *</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="initialWeightG"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
/><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Cost</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="costTotal"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Moisture</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="moisturePct"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">%</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Density</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="densityGL"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">g/L</span>
|
||||
</div></label
|
||||
>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes</span
|
||||
><input class="field-input" name="notes"
|
||||
/></label>
|
||||
<p class="field-note hidden" id="lot-remaining-note"></p>
|
||||
<button class="primary-btn" type="submit" id="lot-form-submit">
|
||||
Add lot
|
||||
</button>
|
||||
<button
|
||||
class="ghost-btn hidden"
|
||||
type="button"
|
||||
id="lot-form-cancel"
|
||||
>
|
||||
Cancel edit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/inventory.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user