Compare commits

..
Author SHA1 Message Date
aarbitandClaude Sonnet 5 71e4c2c22d Pin wrangler and supabase CLI as devDependencies
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
The last pipeline run failed on \`npx wrangler deploy\` with npm unable to
find wrangler@4.144.0 — a transient registry propagation gap right after
that version's release, not an actual config problem (it installs fine
moments later, confirmed locally). Both wrangler and the supabase CLI were
being fetched fresh via bare npx on every single pipeline run instead of
coming from the lockfile like every other dependency, which is exactly
what left CI exposed to whatever npm's registry happens to be doing at
that moment. Pinning them means npm ci resolves the exact locked version
every time, and npx picks up the local install instead of hitting the
registry at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-29 16:35:36 -05:00
aarbitandClaude Sonnet 5 dd78b45c3e Fix marketing site's Log in/Sign up links pointing to prod on staging
ci/woodpecker/push/woodpecker Pipeline failed
site/ has no build step (served as-is, no per-environment templating), so
its three app links were hardcoded to https://app.diagrav.com/ in the
markup — meaning staging.diagrav.com was sending visitors to the production
app instead of staging-app.diagrav.com. Rewrites them at load time based on
location.hostname instead of maintaining a second copy of the file;
defaults to the production app host everywhere except the exact staging
hostname, so production's own links are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-29 16:12:14 -05:00
aarbitandClaude Sonnet 5 a0d62587de Add explicit event filters to install/lint/typecheck steps
ci/woodpecker/push/woodpecker Pipeline was successful
Woodpecker's linter flags steps with no event filter as a "bad habit" (they'd
otherwise run on every event type, including ones added to Woodpecker in the
future). Scoped to [push, deployment] specifically — not just push — since a
production deploy re-runs the whole pipeline as a deployment event, and
deploy-production's build needs these steps (especially install's node_modules)
to have actually run first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-29 15:52:35 -05:00
aarbitandClaude Sonnet 5 7fbe53f025 Fix Woodpecker event name: deployment, not deploy
ci/woodpecker/push/woodpecker Pipeline was successful
Caught from the linter warnings on the first real pipeline run (#1) — the
production-promotion step's event filter used \`deploy\`, an invalid event
name, so it would never have matched Woodpecker's actual deploy-button
event (\`deployment\`) and the production step would have silently never
run. Staging's own deploy (a push-triggered step) was unaffected and
verified working on that same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-29 15:49:44 -05:00
aarbitandClaude Sonnet 5 d847a0f255 Add CI/CD pipeline: Woodpecker, auto-staging / manual-prod
ci/woodpecker/push/woodpecker Pipeline was successful
Adds .woodpecker.yml (lint + typecheck on every push, auto-deploy to a
shared staging environment on every push, manual Deploy-button promotion to
production on main) and env.staging blocks in both wrangler configs so
staging gets its own Workers (diagrav-app-staging/diagrav-site-staging at
staging-app.diagrav.com/staging.diagrav.com) rather than sharing anything
with production.

Staging also got its own fully separate Supabase Cloud project (own
database, own Auth config reusing the same Google OAuth client with an
extra redirect URI, own Resend-backed SMTP) — migrated, seeded with the
public catalog, and verified end-to-end with a real sign-in through the
deployed app before wiring any of this into CI.

Updates deployment-plan.md's CI/CD section to match what actually got
built, superseding the earlier manual-pushbutton-for-both-environments
version of the plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-29 10:36:15 -05:00
aarbitandClaude Sonnet 5 fc37ee5e8b Add npm run dev:lan for phone testing over the LAN
Adds a dev:lan script (vite --host) so testing on a real device doesn't
require permanently changing vite.config.ts's default 127.0.0.1-only
binding. Pairs with a small supabaseClient change: when the configured
Supabase URL is local (127.0.0.1/localhost), the client now swaps in
whatever hostname the page itself was loaded from, so a phone hitting the
dev server's LAN IP gets API calls routed to that same IP automatically —
no more hand-editing VITE_SUPABASE_URL per network. Never touches a real
production Supabase URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 13:43:20 -05:00
aarbitandClaude Sonnet 5 7e37dc5676 Add mobile view-only support
Below md width, the app now forces view-only (same canEdit mechanism as a
shared view-only diagram) rather than trying to support touch editing:
palette and inspector panels hide, editing controls disable, and the top
bar collapses to just Diagrams + Account. A bottom tab bar swaps between a
full-screen canvas and a full-screen Bill of Materials, since there's no
room for both side by side. Canvas gets a few mobile-specific trims too:
MiniMap and the interactivity lock button hide whenever canEdit is false
(the lock button doesn't actually grant editing either way — nodesDraggable/
nodesConnectable already override it — so leaving it visible would just be
misleading), and h-screen is replaced with h-dvh throughout so the layout
doesn't get clipped by a mobile browser's collapsing address bar.

Also fixes quick-add's anti-stack offset (24px — far smaller than a device
node) to actually tile devices apart instead of leaving them nearly
overlapping, and logs a deferred "Auto-arrange" layout feature in
organized-ideas.md, surfaced by mobile visitors having no way to fix an
overlapping diagram themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 13:32:27 -05:00
23 changed files with 2241 additions and 178 deletions
+95
View File
@@ -0,0 +1,95 @@
# See deployment-plan.md's "CI/CD plan" section for the full rationale.
#
# Shape: every push (any branch) lints, typechecks, and auto-deploys to a
# single shared staging environment (its own Cloudflare Workers + its own
# Supabase project — never shares data with production). Production is
# never touched automatically — promoting to it is a manual "Deploy" button
# click on a main-branch pipeline run in the Woodpecker UI (Woodpecker's
# deployment event), which is why deploy-production is gated on
# `event: deployment` rather than `event: push`.
#
# Debian-based node image (not Alpine) for every step: the Supabase CLI's
# downloaded binary has had musl/Alpine compatibility issues in the past.
# Woodpecker shares one workspace across all steps in a pipeline run, so
# `npm ci` in the install step is enough for every later step to reuse.
steps:
# Explicit event filter on these three (rather than the no-`when` default,
# which runs on every event Woodpecker knows about) because a deploy-time
# re-run of this pipeline is a `deployment` event, not `push` — install
# has to fire there too, or deploy-production's `npm run build` would run
# with no node_modules.
- name: install
image: node:22-bookworm
when:
- event: [push, deployment]
commands:
- npm ci
- name: lint
image: node:22-bookworm
when:
- event: [push, deployment]
commands:
- npm run lint
- name: typecheck
image: node:22-bookworm
when:
- event: [push, deployment]
commands:
- npx tsc -b
- name: deploy-staging
image: node:22-bookworm
when:
- event: push
environment:
VITE_SUPABASE_URL:
from_secret: staging_supabase_url
VITE_SUPABASE_ANON_KEY:
from_secret: staging_supabase_anon_key
CLOUDFLARE_API_TOKEN:
from_secret: cloudflare_api_token
CLOUDFLARE_ACCOUNT_ID:
from_secret: cloudflare_account_id
SUPABASE_ACCESS_TOKEN:
from_secret: supabase_access_token
SUPABASE_PROJECT_REF:
from_secret: staging_supabase_project_ref
SUPABASE_DB_PASSWORD:
from_secret: staging_supabase_db_password
commands:
- npm run build
- npx wrangler deploy --config wrangler.app.jsonc --env staging
- npx wrangler deploy --config wrangler.site.jsonc --env staging
- npx supabase db push --project-ref $SUPABASE_PROJECT_REF --password "$SUPABASE_DB_PASSWORD"
- npx supabase functions deploy admin-user-action --project-ref $SUPABASE_PROJECT_REF
- name: deploy-production
image: node:22-bookworm
when:
- event: deployment
branch: main
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
environment:
VITE_SUPABASE_URL:
from_secret: prod_supabase_url
VITE_SUPABASE_ANON_KEY:
from_secret: prod_supabase_anon_key
CLOUDFLARE_API_TOKEN:
from_secret: cloudflare_api_token
CLOUDFLARE_ACCOUNT_ID:
from_secret: cloudflare_account_id
SUPABASE_ACCESS_TOKEN:
from_secret: supabase_access_token
SUPABASE_PROJECT_REF:
from_secret: prod_supabase_project_ref
SUPABASE_DB_PASSWORD:
from_secret: prod_supabase_db_password
commands:
- npm run build
- npx wrangler deploy --config wrangler.app.jsonc
- npx wrangler deploy --config wrangler.site.jsonc
- npx supabase db push --project-ref $SUPABASE_PROJECT_REF --password "$SUPABASE_DB_PASSWORD"
- npx supabase functions deploy admin-user-action --project-ref $SUPABASE_PROJECT_REF
+71 -69
View File
@@ -1,11 +1,11 @@
# Deployment & CI/CD planning notes
Discussion notes only — nothing here has been implemented yet. Written so a
different Claude Code conversation on this repo can pick up where this one
left off without re-deriving it. Nothing below is committed to as final; it's
a record of what was discussed and concluded, plus open decisions.
Started as discussion-only notes; hosting and CI/CD are now both live (see
their sections below for what's actually implemented vs. still open).
Written so a different Claude Code conversation on this repo can pick up
where this one left off without re-deriving it.
## Hosting shape (discussed, not yet implemented)
## Hosting shape — **implemented**
The app is a static frontend (Vite build, no server of its own) + Supabase
backend (Postgres, Auth, RLS, one Edge Function). [organized-ideas.md](organized-ideas.md)
@@ -110,79 +110,81 @@ Estimated cost: **$0/mo** to start (Supabase Free + Vercel/Netlify/Cloudflare
Pages free tier + Resend free tier for auth email), **$25/mo** once you want
Supabase Pro (no project auto-pause, higher limits across the board).
## CI/CD plan: Gitea + Woodpecker, pushbutton staging/prod
## CI/CD: Woodpecker, auto-staging / manual-prod — **implemented**
Repo is self-hosted on Gitea (`git.halfbinary.net/aarbit/av-planner`) with
Woodpecker CI already in use elsewhere. No `.woodpecker.yml` exists yet in
this repo.
Woodpecker CI already in use elsewhere. `.woodpecker.yml` now exists at the
repo root. The flow below supersedes an earlier version of this section that
proposed manual pushbutton deploys for *both* staging and production —
revisited and simplified to auto-deploy staging instead.
**Desired flow**: PR merges to `main` → pipeline runs lint/typecheck/build
and stops → a human clicks a "Deploy" button to push to staging, and
separately clicks it again to promote to production. Not fully automatic on
merge — deploys to both environments are manual/pushbutton.
**Actual flow**: any push (any branch) runs lint + typecheck, then
auto-deploys to a shared staging environment — no button needed. Production
is never touched automatically; promoting to it is a manual **Deploy**
button click (Woodpecker's built-in `deployment` event) on a `main`-branch
pipeline run, typing `production` as the deploy target
(`$CI_PIPELINE_DEPLOY_TARGET`). Requires **"Allow deployments"** enabled in
the repo's Woodpecker project settings.
**Mechanism (confirmed against current Woodpecker docs)**: Woodpecker has a
built-in **deploy event** for exactly this. Enable **"Allow deployments"** in
the repo's Woodpecker project settings, and any successful pipeline run gets
a **Deploy** button in the UI. Clicking it re-runs that pipeline with
`event: deploy` and a `deploy_to` string you type (e.g. `staging` or
`production`), exposed to steps as `$CI_PIPELINE_DEPLOY_TARGET`. Steps gate
on it, e.g.:
**Two fully separate environments exist, each with its own everything** (no
shared data or infra between them, or with production):
```yaml
steps:
- name: deploy-staging
when:
- event: deploy
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "staging"'
- name: deploy-prod
when:
- event: deploy
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
```
| | Staging | Production |
|---|---|---|
| Supabase project | `onovxozixdxpdngxafxt` ("Diagrav Staging") | `wmombshptajyrawdbroj` ("Diagrav") |
| App Worker | `diagrav-app-staging` → `staging-app.diagrav.com` | `diagrav-app` → `app.diagrav.com` |
| Site Worker | `diagrav-site-staging` → `staging.diagrav.com` | `diagrav-site` → `diagrav.com` |
**What needs to exist**:
1. **Two Supabase projects** — staging and prod. This exactly fills the free
tier's "2 active projects" cap (see above) — no free-tier headroom left
for a third project if one is ever wanted later.
2. **Two frontend hosting targets** — two sites/deployments (SaaS host or
self-hosted alongside Gitea/Woodpecker — not yet decided which).
3. **Known wrinkle**: because Vite bakes `VITE_SUPABASE_URL`/`VITE_SUPABASE_ANON_KEY`
in at build time, you can't build once and promote the identical artifact
to both environments — each deploy target needs its own build with its
own env vars baked in. Not hard, just means "deploy" reruns the build
rather than reusing one artifact. A non-issue at this app's scale.
4. **Migrations run per-target** via Supabase CLI, scripted per environment:
```bash
supabase link --project-ref $SUPABASE_PROJECT_REF
supabase db push
supabase functions deploy admin-user-action
```
5. **Secrets needed in Woodpecker** (scoped per target): `SUPABASE_ACCESS_TOKEN`,
staging/prod `SUPABASE_PROJECT_REF`, staging/prod `VITE_SUPABASE_URL` /
`VITE_SUPABASE_ANON_KEY`, plus whatever the frontend host needs (API
token, or SSH key if self-hosted).
6. **Google OAuth**: needs its own authorized redirect URI added in Google
Cloud console for each of the staging and prod domains — one-time setup,
easy to forget.
This uses both of Supabase Free's "2 active projects" slots — no headroom
for a third project if one's ever wanted later (see cost findings above).
**Difficulty estimate discussed**: roughly a focused afternoon — nothing here
fights the tooling (no containers to orchestrate, no server process to run).
Breakdown: `.woodpecker.yml` (~1–2 hrs), second Supabase project + migration
check (~30 min), frontend hosting target wiring (~30–60 min), secrets +
"Allow deployments" toggle (~15 min), end-to-end test/debug (~1–2 hrs).
**How each piece works**:
- **Wrangler**: `wrangler.app.jsonc` / `wrangler.site.jsonc` each gained an
`env.staging` block (own `name` + own `routes`) — `assets.directory` isn't
per-environment in Wrangler, which is fine, since it's rebuilt with
different `VITE_*` values before each deploy anyway (the "known wrinkle"
below).
- **Known wrinkle (as anticipated)**: Vite bakes `VITE_SUPABASE_URL`/
`VITE_SUPABASE_ANON_KEY` in at build time, so `npm run build` reruns once
per target with that target's env vars set, rather than promoting one
artifact between environments.
- **Migrations**: `supabase db push --project-ref $REF --password
$SUPABASE_DB_PASSWORD` per target (no persistent `supabase link` — every
command that needs a project takes `--project-ref` directly, which also
meant this never touched the local repo's own `supabase link` state,
still pointed at production for everyday local dev).
- **Google OAuth**: staging reuses production's *same* OAuth client rather
than a new one — just one more authorized redirect URI
(`https://onovxozixdxpdngxafxt.supabase.co/auth/v1/callback`) added to it
in Google Cloud Console, since a client can authorize many redirect URIs.
- **SMTP**: staging reuses the same Resend account/verified domain as
production, with a distinct `smtp_sender_name` ("Diagrav (Staging)") so
the two are distinguishable in an inbox.
- **Staging catalog data**: seeded once by applying `supabase/seed.sql`
directly against the new project (not part of the pipeline — a one-time
setup step, same as production's original seeding).
**Secrets in Woodpecker** (repo Settings → Secrets): `cloudflare_api_token`,
`cloudflare_account_id`, `supabase_access_token` (shared); per-target
`{staging,prod}_supabase_url`, `{staging,prod}_supabase_anon_key`,
`{staging,prod}_supabase_project_ref`, `{staging,prod}_supabase_db_password`.
**Outstanding**: the production `supabase_db_password` secret — Supabase
doesn't expose an existing project's DB password via the Management API, so
this needs either the password from wherever it was originally saved, or a
deliberate decision to rotate it (a live production credential change,
not something to do silently). The staging path has been fully verified
end-to-end (migrations, seed, Auth, a real sign-in through the deployed
app); production's manual deploy path has not been exercised yet — no
reason to touch real prod while proving the pipeline out.
## Open decisions (not yet made)
- Which frontend host: SaaS (Vercel/Netlify/Cloudflare Pages) vs self-hosted
alongside the existing Gitea/Woodpecker infra.
- Where the marketing site (`site/`, see above) lives relative to the app —
leaning subdomain (`diagrav.com` + `app.diagrav.com`, per the links already
in `site/index.html`) over same-domain/different-path, but not finalized —
and whether it gets its own line in the eventual `.woodpecker.yml` or
piggybacks on the frontend deploy step.
- Frontend host and the marketing site's placement are both **decided and
live**, superseding the two bullets that used to be here: Cloudflare
Workers (assets-only, no server code) for both, subdomain split
(`diagrav.com` marketing site + `app.diagrav.com` app), matching the
links already in `site/index.html`.
- Whether/when to add diagram-snapshot retention (the storage-risk item
above) — not done yet, just identified.
- Actual `.woodpecker.yml` has not been written yet — this doc captures the
plan/shape only, per the user's request to keep this conversation
discussion-only and not make repo changes here.
- The production `supabase_db_password` gap noted above.
+1
View File
@@ -82,6 +82,7 @@ Stated explicitly during planning: this needs to run **entirely locally** during
- **Recommended:** whatever visual grouping happens in compact view must be purely a canvas rendering concern — the BOM math (cable counts, lengths, totals) always operates on the real underlying connections, never on the aggregated display. Otherwise compact view could silently produce a wrong shopping list.
- **Implementation detail, still loose:** exactly what "connection-type group" means for aggregation purposes isn't fully pinned down — grouped by cable type, by port family, by direction, some combination? Low-stakes to leave open since this is frontend-only and easy to iterate on visually once it's being built.
- **Decided: toggle scope.** Per-device (each device node expands/collapses independently), defaulting to **compact** for a newly-placed device — consistent with the palette categories already defaulting to collapsed. Plus **"Expand all" / "Collapse all"** buttons on the canvas for quickly toggling every device at once, rather than clicking through each one individually.
- **Future TODO, not yet built: an "Auto-arrange" layout action.** Surfaced while building mobile view-only support (2026-09-28) — nothing stops two devices' saved positions from overlapping (only quick-add's own placement got a bigger anti-stack grid), and expanding a device grows its node size, which can turn a fine compact layout into an overlapping expanded one. On desktop this was always self-correctable (whoever's looking has edit access and can just drag nodes apart), but a mobile/view-only visitor now has no way to fix it themselves — they're stuck with whatever layout the diagram's owner left. Needs a real graph-layout pass (something like `dagre`), a decision on when it runs (probably a manual button rather than automatic-on-load, so a diagram doesn't silently rearrange itself), and to account for compact vs. expanded node sizing. Deliberately deferred rather than built speculatively — it's a standalone feature, not something to fold into the mobile branch.
## 5. Device Menus
+1763 -1
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:lan": "vite --host",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
@@ -27,8 +28,10 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"supabase": "^2.118.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.2"
"vite": "^8.2.2",
"wrangler": "^4.143.1"
}
}
+19 -3
View File
@@ -24,8 +24,8 @@
<a href="#library">Library</a>
</div>
<div class="nav-auth">
<a class="nav-login" href="https://app.diagrav.com/">Log in</a>
<a class="btn btn-primary btn-sm" href="https://app.diagrav.com/">Sign up</a>
<a class="nav-login app-link" href="https://app.diagrav.com/">Log in</a>
<a class="btn btn-primary btn-sm app-link" href="https://app.diagrav.com/">Sign up</a>
</div>
</div>
</div>
@@ -158,7 +158,7 @@
<div class="strip">
<h2>Stop guessing which cable you need</h2>
<p>Plan the wiring first. Buy the right cables once.</p>
<a class="btn btn-primary btn-lg strip-cta" href="https://app.diagrav.com/">Sign up</a>
<a class="btn btn-primary btn-lg strip-cta app-link" href="https://app.diagrav.com/">Sign up</a>
</div>
</div>
</section>
@@ -170,5 +170,21 @@
</div>
</footer>
<script>
// site/ has no build step (served as-is, no per-environment templating),
// so the app links above are hardcoded to production in the markup —
// this is the one place that needs to differ on staging.diagrav.com,
// rewritten at load time rather than maintaining a second copy of this
// file. Defaults to production's app host for anything else (the real
// domain, a bare file preview, etc.) — the safe assumption everywhere
// that isn't specifically the staging hostname.
(function () {
var appHost = location.hostname === 'staging.diagrav.com' ? 'staging-app.diagrav.com' : 'app.diagrav.com';
document.querySelectorAll('.app-link').forEach(function (a) {
a.href = 'https://' + appHost + '/';
});
})();
</script>
</body>
</html>
+2 -2
View File
@@ -32,7 +32,7 @@ export default function App() {
}, [session])
if (session === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (!session) {
@@ -40,7 +40,7 @@ export default function App() {
}
if (needsUsername === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (needsUsername) {
@@ -39,7 +39,7 @@ export default function CompleteProfileScreen({ onDone }: { onDone: () => void }
}
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<div className="flex h-dvh items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">One more thing</h1>
<p className="mt-1 text-xs text-slate-500">Choose a username to finish setting up your account.</p>
+2 -2
View File
@@ -60,7 +60,7 @@ export default function LoginScreen() {
if (confirmSent) {
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<div className="flex h-dvh items-center justify-center bg-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm">
<h1 className="text-sm font-semibold text-slate-800">Check your email</h1>
<p className="mt-2 text-xs text-slate-500">
@@ -91,7 +91,7 @@ export default function LoginScreen() {
}
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<div className="flex h-dvh items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">Diagrav</h1>
<p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p>
+17 -8
View File
@@ -26,6 +26,8 @@ import {
} from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import { useIsMobile } from '../../hooks/useIsMobile'
import CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
@@ -65,13 +67,11 @@ function FlowCanvasInner() {
const selectConnection = useDiagramStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow()
const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
// 'edit') can edit; a view-only collaborator can look but not touch —
// organized-ideas.md §8's per-collaborator permissions. `access` is only
// null in the brief window before the first diagram finishes loading, at
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
const canEdit = access?.myPermission !== 'view'
// 'edit') can edit; a view-only collaborator, or anyone on a mobile-width
// viewport, can look but not touch — see useCanEdit.
const canEdit = useCanEdit()
const isMobile = useIsMobile()
// Per-device compact/expanded toggle (organized-ideas.md §4) — purely a
// canvas display preference, so it's local component state rather than
@@ -398,8 +398,17 @@ function FlowCanvasInner() {
colorMode="light"
>
<Background />
<Controls />
<MiniMap pannable zoomable className="!bg-white" />
{/* showInteractive is React Flow's own lock-toggle button, and it's
misleading whenever canEdit is already false (shared view-only,
or mobile) — dragging/connecting are gated by nodesDraggable/
nodesConnectable above regardless of this button's state, so
"unlocking" it wouldn't actually grant editing, just click-to-
select. Hiding it avoids implying otherwise. */}
<Controls showInteractive={canEdit} />
{/* Takes a meaningful chunk of a phone-sized viewport for not much
payoff — pan/pinch-zoom plus fitView already gets you oriented
without it. */}
{!isMobile && <MiniMap pannable zoomable className="!bg-white" />}
<Panel position="top-right" className="flex gap-1.5">
<button
onClick={expandAll}
@@ -2,12 +2,12 @@ import { useMemo, useState } from 'react'
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const canEdit = useCanEdit()
const updateConnection = useDiagramStore((s) => s.updateConnection)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const startBundle = useDiagramStore((s) => s.startBundle)
+2 -2
View File
@@ -4,14 +4,14 @@ import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const canEdit = useCanEdit()
const updateDevice = useDiagramStore((s) => s.updateDevice)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const addPort = useDiagramStore((s) => s.addPort)
+23 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAnnouncementStore } from '../../state/announcementStore'
import { useAuthStore } from '../../state/authStore'
@@ -6,15 +6,22 @@ import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import AnnouncementBanner from '../announcements/AnnouncementBanner'
import BomPanel from '../bom/BomPanel'
import FlowCanvas from '../canvas/FlowCanvas'
import DevicePalette from '../palette/DevicePalette'
import ConnectionErrorBanner from './ConnectionErrorBanner'
import MobileTabBar, { type MobileTab } from './MobileTabBar'
import RightPanel from './RightPanel'
import TopBar from './TopBar'
import ViewOnlyBanner from './ViewOnlyBanner'
export default function AppShell() {
const isLoaded = useDiagramStore((s) => s.isLoaded)
// Below `md`, RightPanel (and its own Inspector/BOM tabs) isn't shown at
// all — this stands in for just the BOM half, via MobileTabBar. Ignored
// entirely at `md` and up, so it doesn't need to track viewport width
// itself; the two panes' own classNames below do that.
const [mobileTab, setMobileTab] = useState<MobileTab>('diagram')
const loadInitialDiagram = useDiagramStore((s) => s.loadInitialDiagram)
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
@@ -40,22 +47,35 @@ export default function AppShell() {
}, [role, loadAdminQueue])
if (!isLoaded || !isCatalogLoaded) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
}
return (
<div className="flex h-screen flex-col">
<div className="flex h-dvh flex-col">
<AnnouncementBanner />
<TopBar />
<div className="flex min-h-0 flex-1">
<DevicePalette />
<main className="relative min-w-0 flex-1">
<ConnectionErrorBanner />
{/* Both panes stay mounted so switching tabs on mobile doesn't
reset canvas pan/zoom or expanded-device state — only which
one is visible changes. At `md` and up, the diagram pane is
always the one shown (RightPanel has its own BOM tab there),
regardless of mobileTab. ViewOnlyBanner lives inside this pane,
not as a shared sibling — it's about canvas editing, so on
mobile it shouldn't float over the BOM pane too. */}
<div className={mobileTab === 'diagram' ? 'h-full' : 'hidden md:block md:h-full'}>
<ViewOnlyBanner />
<FlowCanvas />
</div>
<div className={`h-full overflow-y-auto md:hidden ${mobileTab === 'bom' ? 'block' : 'hidden'}`}>
<BomPanel />
</div>
</main>
<RightPanel />
</div>
<MobileTabBar tab={mobileTab} onChange={setMobileTab} />
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export type MobileTab = 'diagram' | 'bom'
/** Bottom nav for the mobile view-only layout (AppShell) — stands in for
* the palette + RightPanel that only fit at `md` and up. Just two views:
* there's no editing to switch an Inspector tab for on mobile (see
* useCanEdit), so "Diagram" and "Bill of Materials" are the whole set. */
export default function MobileTabBar({ tab, onChange }: { tab: MobileTab; onChange: (tab: MobileTab) => void }) {
return (
<nav className="flex h-12 shrink-0 border-t border-slate-200 bg-white md:hidden">
<button
onClick={() => onChange('diagram')}
className={`flex-1 text-xs font-semibold uppercase tracking-wide ${
tab === 'diagram' ? 'text-indigo-700' : 'text-slate-400'
}`}
>
Diagram
</button>
<button
onClick={() => onChange('bom')}
className={`flex-1 text-xs font-semibold uppercase tracking-wide ${
tab === 'bom' ? 'text-indigo-700' : 'text-slate-400'
}`}
>
Bill of Materials
</button>
</nav>
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function RightPanel() {
const [tab, setTab] = useState<Tab>('inspector')
return (
<aside className="flex h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white">
<aside className="hidden h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white md:flex">
<div className="flex border-b border-slate-200">
<button
onClick={() => setTab('inspector')}
+13 -5
View File
@@ -5,6 +5,7 @@ import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import AdminUsersModal from '../admin/AdminUsersModal'
import ProfileModal from '../account/ProfileModal'
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
@@ -37,8 +38,7 @@ function setLastSeenSubmissionsAt(iso: string): void {
export default function TopBar() {
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const canEdit = useCanEdit()
const renameDiagram = useDiagramStore((s) => s.renameDiagram)
const importDiagram = useDiagramStore((s) => s.importDiagram)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
@@ -92,13 +92,13 @@ export default function TopBar() {
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">Diagrav</span>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-semibold text-indigo-700">Diagrav</span>
<input
value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)}
disabled={!canEdit}
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
className="w-28 min-w-0 rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:w-48 md:w-64"
/>
</div>
<div className="flex items-center gap-1.5">
@@ -108,6 +108,13 @@ export default function TopBar() {
>
Diagrams
</button>
{/* Editing/reviewing actions below aren't useful on a mobile-width
viewport (view-only there via useCanEdit) — hidden rather than
removed so they come straight back if the window's just narrow,
not actually a phone (see useIsMobile). "Diagrams" above and
"Account" below stay: picking which diagram to look at, and
signing out, are both still things a mobile visitor might want. */}
<div className="hidden md:contents">
<DropdownMenu label="Diagram">
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
@@ -166,6 +173,7 @@ export default function TopBar() {
)}
</DropdownMenu>
)}
</div>
<DropdownMenu label={username ?? 'Account'} align="right">
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
+24 -9
View File
@@ -1,18 +1,33 @@
import { useDiagramStore } from '../../state/diagramStore'
import { useIsMobile } from '../../hooks/useIsMobile'
/** Shown when the current user only has view access to the open diagram
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and
* inspectors disable their edit controls in this case too (see
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that
* visible instead of leaving it to be discovered by a control not working. */
/** Shown whenever the canvas/inspectors have disabled their edit controls
* (see useCanEdit) — either because this collaborator only has view access
* to the diagram (organized-ideas.md §8's per-collaborator permissions), or
* because the viewport is mobile-width, where editing isn't a realistic
* touch interaction. Distinguishes the two messages: an owner viewing their
* own diagram on their phone does have edit access, just not here. */
export default function ViewOnlyBanner() {
const access = useDiagramStore((s) => s.access)
if (access?.myPermission !== 'view') return null
const isMobile = useIsMobile()
const sharedViewOnly = access?.myPermission === 'view'
if (!sharedViewOnly && !isMobile) return null
return (
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
View only — you don't have edit access to this diagram.
<div
className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center px-3 ${
// On mobile this centered banner is wide enough to reach the
// Expand-all/Collapse-all buttons (FlowCanvas's top-right Panel) —
// there's no room to dodge them sideways, so it sits a row lower
// there instead. Desktop's canvas is wide enough that top-3 never
// reaches that corner.
isMobile ? 'top-14' : 'top-3'
}`}
>
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-center text-xs text-slate-600 shadow-sm">
{sharedViewOnly
? "View only — you don't have edit access to this diagram."
: 'View only on mobile — switch to a larger screen to edit.'}
</div>
</div>
)
+20 -6
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import Chevron from '../common/Chevron'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
@@ -55,8 +56,7 @@ function sortGroupsByLabel<T extends { key: string; label: string }>(groups: T[]
export default function DevicePalette() {
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const canEdit = useCanEdit()
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const catalog = useCatalogStore((s) => s.catalog)
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
@@ -153,9 +153,23 @@ export default function DevicePalette() {
const handleQuickAdd = (template: DeviceTemplate) => {
if (!canEdit) return
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
const offset = (diagram.devices.length % 8) * 24
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
// Tiled placement so repeated quick-adds land visibly apart rather than
// overlapping — a compact device node is ~224px wide, expanded ~288px,
// so the step sizes below clear both. Wraps into a new row rather than
// an ever-longer diagonal, so a lot of quick-adds still stay reachable
// without much panning. This only governs *new* devices' starting
// position — it's not a general anti-overlap layout (see
// organized-ideas.md's auto-arrange discussion for that).
const QUICK_ADD_GRID_COLS = 4
const QUICK_ADD_STEP_X = 320
const QUICK_ADD_STEP_Y = 220
const index = diagram.devices.length
const col = index % QUICK_ADD_GRID_COLS
const row = Math.floor(index / QUICK_ADD_GRID_COLS)
addDeviceFromTemplate(catalog, template.id, {
x: 80 + col * QUICK_ADD_STEP_X,
y: 80 + row * QUICK_ADD_STEP_Y,
})
}
const handleDelete = (template: DeviceTemplate) => {
@@ -236,7 +250,7 @@ export default function DevicePalette() {
}
return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
<aside className="hidden h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50 md:flex">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<div className="flex items-center gap-1.5">
+17 -3
View File
@@ -1,13 +1,27 @@
import { createClient } from '@supabase/supabase-js'
const url = import.meta.env.VITE_SUPABASE_URL
const rawUrl = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!url || !anonKey) {
if (!rawUrl || !anonKey) {
throw new Error(
'Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Copy .env.example to .env.local and fill them in ' +
'(run `npx supabase start` then `npx supabase status` for local values).',
)
}
export const supabase = createClient(url, anonKey)
/** Local Supabase (127.0.0.1/localhost) only: swap in whatever hostname the
* page itself was loaded from. Lets `npm run dev:lan` + a phone on the same
* Wi-Fi work without ever hand-editing VITE_SUPABASE_URL — loading the app
* via the LAN IP makes API calls target that same IP instead of a loopback
* address the phone can't reach. Never touches a real (non-local)
* production URL, so this is a no-op outside local dev. */
function resolveUrl(value: string): string {
const url = new URL(value)
if (url.hostname === '127.0.0.1' || url.hostname === 'localhost') {
url.hostname = window.location.hostname
}
return url.toString()
}
export const supabase = createClient(resolveUrl(rawUrl), anonKey)
+18
View File
@@ -0,0 +1,18 @@
import { useDiagramStore } from '../state/diagramStore'
import { useIsMobile } from './useIsMobile'
/**
* Whether the current user can edit the open diagram right now. False for a
* view-only collaborator (organized-ideas.md §8's per-collaborator
* permissions) — and, independently, on a mobile-width viewport, where
* drag-and-drop from the palette and precise port-to-port wiring aren't a
* realistic touch interaction. See ViewOnlyBanner for the matching message.
* `access` is only null in the brief window before the first diagram
* finishes loading, at which point nothing is rendered yet anyway
* (AppShell's isLoaded gate).
*/
export function useCanEdit(): boolean {
const access = useDiagramStore((s) => s.access)
const isMobile = useIsMobile()
return access?.myPermission !== 'view' && !isMobile
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
// Matches the `md` breakpoint everywhere else in the layout (AppShell,
// DevicePalette, RightPanel) so "mobile" here means exactly "the width
// those panels disappear at" — not an independently-tuned value.
const QUERY = '(max-width: 767px)'
function getIsMobile(): boolean {
return typeof window !== 'undefined' && window.matchMedia(QUERY).matches
}
/** Live viewport-width check (not a one-time device/UA sniff) — updates on
* rotation or a resized window, which matters for a resizable desktop
* browser window as much as an actual phone. */
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(getIsMobile)
useEffect(() => {
const mql = window.matchMedia(QUERY)
const onChange = () => setIsMobile(mql.matches)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}, [])
return isMobile
}
+20 -2
View File
@@ -1,7 +1,9 @@
// Deploys the built Vite app (dist/) as an assets-only Cloudflare Worker.
// No server-side code — this is purely static hosting for app.diagrav.com.
// Build first (with production VITE_* env vars baked in), then:
// npx wrangler deploy --config wrangler.app.jsonc
// Build first (with the right VITE_* env vars baked in for the target
// environment — see the CI/CD section of deployment-plan.md), then:
// npx wrangler deploy --config wrangler.app.jsonc (production)
// npx wrangler deploy --config wrangler.app.jsonc --env staging
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "diagrav-app",
@@ -15,5 +17,21 @@
"pattern": "app.diagrav.com",
"custom_domain": true
}
],
// Own Worker, own route, own build (with staging's VITE_SUPABASE_* baked
// in) — never shares runtime state with production. `assets.directory`
// isn't overridable per-environment in Wrangler, which is fine here: it's
// the same dist/ folder either way, just rebuilt with different env vars
// immediately before each deploy.
"env": {
"staging": {
"name": "diagrav-app-staging",
"routes": [
{
"pattern": "staging-app.diagrav.com",
"custom_domain": true
}
]
}
}
}
+15 -1
View File
@@ -1,7 +1,8 @@
// Deploys the hand-written marketing site (site/) as an assets-only
// Cloudflare Worker for the bare diagrav.com domain. No build step —
// serves the folder as-is:
// npx wrangler deploy --config wrangler.site.jsonc
// npx wrangler deploy --config wrangler.site.jsonc (production)
// npx wrangler deploy --config wrangler.site.jsonc --env staging
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "diagrav-site",
@@ -15,5 +16,18 @@
"pattern": "diagrav.com",
"custom_domain": true
}
],
// Own Worker, own route — the marketing site has no build step/env vars,
// so this is just a separate deploy target, not a separate build.
"env": {
"staging": {
"name": "diagrav-site-staging",
"routes": [
{
"pattern": "staging.diagrav.com",
"custom_domain": true
}
]
}
}
}