Compare commits
8
Commits
3344f89a8a
...
e4c36e2960
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c36e2960 | ||
|
|
71e4c2c22d | ||
|
|
dd78b45c3e | ||
|
|
a0d62587de | ||
|
|
7fbe53f025 | ||
|
|
d847a0f255 | ||
|
|
fc37ee5e8b | ||
|
|
7e37dc5676 |
@@ -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
@@ -1,11 +1,11 @@
|
|||||||
# Deployment & CI/CD planning notes
|
# Deployment & CI/CD planning notes
|
||||||
|
|
||||||
Discussion notes only — nothing here has been implemented yet. Written so a
|
Started as discussion-only notes; hosting and CI/CD are now both live (see
|
||||||
different Claude Code conversation on this repo can pick up where this one
|
their sections below for what's actually implemented vs. still open).
|
||||||
left off without re-deriving it. Nothing below is committed to as final; it's
|
Written so a different Claude Code conversation on this repo can pick up
|
||||||
a record of what was discussed and concluded, plus open decisions.
|
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
|
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)
|
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
|
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).
|
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
|
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
|
Woodpecker CI already in use elsewhere. `.woodpecker.yml` now exists at the
|
||||||
this repo.
|
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
|
**Actual flow**: any push (any branch) runs lint + typecheck, then
|
||||||
and stops → a human clicks a "Deploy" button to push to staging, and
|
auto-deploys to a shared staging environment — no button needed. Production
|
||||||
separately clicks it again to promote to production. Not fully automatic on
|
is never touched automatically; promoting to it is a manual **Deploy**
|
||||||
merge — deploys to both environments are manual/pushbutton.
|
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
|
**Two fully separate environments exist, each with its own everything** (no
|
||||||
built-in **deploy event** for exactly this. Enable **"Allow deployments"** in
|
shared data or infra between them, or with production):
|
||||||
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.:
|
|
||||||
|
|
||||||
```yaml
|
| | Staging | Production |
|
||||||
steps:
|
|---|---|---|
|
||||||
- name: deploy-staging
|
| Supabase project | `onovxozixdxpdngxafxt` ("Diagrav Staging") | `wmombshptajyrawdbroj` ("Diagrav") |
|
||||||
when:
|
| App Worker | `diagrav-app-staging` → `staging-app.diagrav.com` | `diagrav-app` → `app.diagrav.com` |
|
||||||
- event: deploy
|
| Site Worker | `diagrav-site-staging` → `staging.diagrav.com` | `diagrav-site` → `diagrav.com` |
|
||||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "staging"'
|
|
||||||
- name: deploy-prod
|
|
||||||
when:
|
|
||||||
- event: deploy
|
|
||||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
|
|
||||||
```
|
|
||||||
|
|
||||||
**What needs to exist**:
|
This uses both of Supabase Free's "2 active projects" slots — no headroom
|
||||||
1. **Two Supabase projects** — staging and prod. This exactly fills the free
|
for a third project if one's ever wanted later (see cost findings above).
|
||||||
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.
|
|
||||||
|
|
||||||
**Difficulty estimate discussed**: roughly a focused afternoon — nothing here
|
**How each piece works**:
|
||||||
fights the tooling (no containers to orchestrate, no server process to run).
|
- **Wrangler**: `wrangler.app.jsonc` / `wrangler.site.jsonc` each gained an
|
||||||
Breakdown: `.woodpecker.yml` (~1–2 hrs), second Supabase project + migration
|
`env.staging` block (own `name` + own `routes`) — `assets.directory` isn't
|
||||||
check (~30 min), frontend hosting target wiring (~30–60 min), secrets +
|
per-environment in Wrangler, which is fine, since it's rebuilt with
|
||||||
"Allow deployments" toggle (~15 min), end-to-end test/debug (~1–2 hrs).
|
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)
|
## Open decisions (not yet made)
|
||||||
|
|
||||||
- Which frontend host: SaaS (Vercel/Netlify/Cloudflare Pages) vs self-hosted
|
- Frontend host and the marketing site's placement are both **decided and
|
||||||
alongside the existing Gitea/Woodpecker infra.
|
live**, superseding the two bullets that used to be here: Cloudflare
|
||||||
- Where the marketing site (`site/`, see above) lives relative to the app —
|
Workers (assets-only, no server code) for both, subdomain split
|
||||||
leaning subdomain (`diagrav.com` + `app.diagrav.com`, per the links already
|
(`diagrav.com` marketing site + `app.diagrav.com` app), matching the
|
||||||
in `site/index.html`) over same-domain/different-path, but not finalized —
|
links already in `site/index.html`.
|
||||||
and whether it gets its own line in the eventual `.woodpecker.yml` or
|
|
||||||
piggybacks on the frontend deploy step.
|
|
||||||
- Whether/when to add diagram-snapshot retention (the storage-risk item
|
- Whether/when to add diagram-snapshot retention (the storage-risk item
|
||||||
above) — not done yet, just identified.
|
above) — not done yet, just identified.
|
||||||
- Actual `.woodpecker.yml` has not been written yet — this doc captures the
|
- The production `supabase_db_password` gap noted above.
|
||||||
plan/shape only, per the user's request to keep this conversation
|
|
||||||
discussion-only and not make repo changes here.
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
## 5. Device Menus
|
||||||
|
|
||||||
|
|||||||
Generated
+1763
-1
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:lan": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -27,8 +28,10 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"oxlint": "^1.79.0",
|
"oxlint": "^1.79.0",
|
||||||
|
"supabase": "^2.118.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2",
|
||||||
|
"wrangler": "^4.143.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -24,8 +24,8 @@
|
|||||||
<a href="#library">Library</a>
|
<a href="#library">Library</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-auth">
|
<div class="nav-auth">
|
||||||
<a class="nav-login" href="https://app.diagrav.com/">Log in</a>
|
<a class="nav-login app-link" 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="btn btn-primary btn-sm app-link" href="https://app.diagrav.com/">Sign up</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
<div class="strip">
|
<div class="strip">
|
||||||
<h2>Stop guessing which cable you need</h2>
|
<h2>Stop guessing which cable you need</h2>
|
||||||
<p>Plan the wiring first. Buy the right cables once.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -170,5 +170,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ export default function App() {
|
|||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
if (session === undefined) {
|
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) {
|
if (!session) {
|
||||||
@@ -40,7 +40,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsUsername === undefined) {
|
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) {
|
if (needsUsername) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function CompleteProfileScreen({ onDone }: { onDone: () => void }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<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>
|
<p className="mt-1 text-xs text-slate-500">Choose a username to finish setting up your account.</p>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
if (confirmSent) {
|
if (confirmSent) {
|
||||||
return (
|
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">
|
<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>
|
<h1 className="text-sm font-semibold text-slate-800">Check your email</h1>
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
@@ -91,7 +91,7 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<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>
|
<p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
} from '../../domain/diagram'
|
} from '../../domain/diagram'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
|
import { useCanEdit } from '../../hooks/useCanEdit'
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||||
import CableEdge, { type CableFlowEdge } from './CableEdge'
|
import CableEdge, { type CableFlowEdge } from './CableEdge'
|
||||||
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
|
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
|
||||||
import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
|
import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
|
||||||
@@ -65,13 +67,11 @@ function FlowCanvasInner() {
|
|||||||
const selectConnection = useDiagramStore((s) => s.selectConnection)
|
const selectConnection = useDiagramStore((s) => s.selectConnection)
|
||||||
const { screenToFlowPosition } = useReactFlow()
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const access = useDiagramStore((s) => s.access)
|
|
||||||
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
|
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
|
||||||
// 'edit') can edit; a view-only collaborator can look but not touch —
|
// 'edit') can edit; a view-only collaborator, or anyone on a mobile-width
|
||||||
// organized-ideas.md §8's per-collaborator permissions. `access` is only
|
// viewport, can look but not touch — see useCanEdit.
|
||||||
// null in the brief window before the first diagram finishes loading, at
|
const canEdit = useCanEdit()
|
||||||
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
|
const isMobile = useIsMobile()
|
||||||
const canEdit = access?.myPermission !== 'view'
|
|
||||||
|
|
||||||
// Per-device compact/expanded toggle (organized-ideas.md §4) — purely a
|
// Per-device compact/expanded toggle (organized-ideas.md §4) — purely a
|
||||||
// canvas display preference, so it's local component state rather than
|
// canvas display preference, so it's local component state rather than
|
||||||
@@ -398,8 +398,17 @@ function FlowCanvasInner() {
|
|||||||
colorMode="light"
|
colorMode="light"
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
{/* showInteractive is React Flow's own lock-toggle button, and it's
|
||||||
<MiniMap pannable zoomable className="!bg-white" />
|
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">
|
<Panel position="top-right" className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={expandAll}
|
onClick={expandAll}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { useMemo, useState } from 'react'
|
|||||||
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
|
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
|
import { useCanEdit } from '../../hooks/useCanEdit'
|
||||||
|
|
||||||
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
||||||
const diagram = useDiagramStore((s) => s.diagram)
|
const diagram = useDiagramStore((s) => s.diagram)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const access = useDiagramStore((s) => s.access)
|
const canEdit = useCanEdit()
|
||||||
const canEdit = access?.myPermission !== 'view'
|
|
||||||
const updateConnection = useDiagramStore((s) => s.updateConnection)
|
const updateConnection = useDiagramStore((s) => s.updateConnection)
|
||||||
const removeConnection = useDiagramStore((s) => s.removeConnection)
|
const removeConnection = useDiagramStore((s) => s.removeConnection)
|
||||||
const startBundle = useDiagramStore((s) => s.startBundle)
|
const startBundle = useDiagramStore((s) => s.startBundle)
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { allPortTypes } from '../../domain/diagram'
|
|||||||
import type { PortDirection } from '../../domain/types'
|
import type { PortDirection } from '../../domain/types'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
|
import { useCanEdit } from '../../hooks/useCanEdit'
|
||||||
import CategorySelect from '../common/CategorySelect'
|
import CategorySelect from '../common/CategorySelect'
|
||||||
import ManufacturerSelect from '../common/ManufacturerSelect'
|
import ManufacturerSelect from '../common/ManufacturerSelect'
|
||||||
|
|
||||||
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||||
const diagram = useDiagramStore((s) => s.diagram)
|
const diagram = useDiagramStore((s) => s.diagram)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const access = useDiagramStore((s) => s.access)
|
const canEdit = useCanEdit()
|
||||||
const canEdit = access?.myPermission !== 'view'
|
|
||||||
const updateDevice = useDiagramStore((s) => s.updateDevice)
|
const updateDevice = useDiagramStore((s) => s.updateDevice)
|
||||||
const removeDevice = useDiagramStore((s) => s.removeDevice)
|
const removeDevice = useDiagramStore((s) => s.removeDevice)
|
||||||
const addPort = useDiagramStore((s) => s.addPort)
|
const addPort = useDiagramStore((s) => s.addPort)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||||
import { useAnnouncementStore } from '../../state/announcementStore'
|
import { useAnnouncementStore } from '../../state/announcementStore'
|
||||||
import { useAuthStore } from '../../state/authStore'
|
import { useAuthStore } from '../../state/authStore'
|
||||||
@@ -6,15 +6,22 @@ import { useCatalogStore } from '../../state/catalogStore'
|
|||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import AnnouncementBanner from '../announcements/AnnouncementBanner'
|
import AnnouncementBanner from '../announcements/AnnouncementBanner'
|
||||||
|
import BomPanel from '../bom/BomPanel'
|
||||||
import FlowCanvas from '../canvas/FlowCanvas'
|
import FlowCanvas from '../canvas/FlowCanvas'
|
||||||
import DevicePalette from '../palette/DevicePalette'
|
import DevicePalette from '../palette/DevicePalette'
|
||||||
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
||||||
|
import MobileTabBar, { type MobileTab } from './MobileTabBar'
|
||||||
import RightPanel from './RightPanel'
|
import RightPanel from './RightPanel'
|
||||||
import TopBar from './TopBar'
|
import TopBar from './TopBar'
|
||||||
import ViewOnlyBanner from './ViewOnlyBanner'
|
import ViewOnlyBanner from './ViewOnlyBanner'
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const isLoaded = useDiagramStore((s) => s.isLoaded)
|
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 loadInitialDiagram = useDiagramStore((s) => s.loadInitialDiagram)
|
||||||
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
||||||
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
||||||
@@ -40,22 +47,35 @@ export default function AppShell() {
|
|||||||
}, [role, loadAdminQueue])
|
}, [role, loadAdminQueue])
|
||||||
|
|
||||||
if (!isLoaded || !isCatalogLoaded) {
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<AnnouncementBanner />
|
<AnnouncementBanner />
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<DevicePalette />
|
<DevicePalette />
|
||||||
<main className="relative min-w-0 flex-1">
|
<main className="relative min-w-0 flex-1">
|
||||||
<ConnectionErrorBanner />
|
<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 />
|
<ViewOnlyBanner />
|
||||||
<FlowCanvas />
|
<FlowCanvas />
|
||||||
|
</div>
|
||||||
|
<div className={`h-full overflow-y-auto md:hidden ${mobileTab === 'bom' ? 'block' : 'hidden'}`}>
|
||||||
|
<BomPanel />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<RightPanel />
|
<RightPanel />
|
||||||
</div>
|
</div>
|
||||||
|
<MobileTabBar tab={mobileTab} onChange={setMobileTab} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export default function RightPanel() {
|
|||||||
const [tab, setTab] = useState<Tab>('inspector')
|
const [tab, setTab] = useState<Tab>('inspector')
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex border-b border-slate-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab('inspector')}
|
onClick={() => setTab('inspector')}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAdminReviewStore } from '../../state/adminReviewStore'
|
|||||||
import { useAuthStore } from '../../state/authStore'
|
import { useAuthStore } from '../../state/authStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import { useCanEdit } from '../../hooks/useCanEdit'
|
||||||
import AdminUsersModal from '../admin/AdminUsersModal'
|
import AdminUsersModal from '../admin/AdminUsersModal'
|
||||||
import ProfileModal from '../account/ProfileModal'
|
import ProfileModal from '../account/ProfileModal'
|
||||||
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
|
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
|
||||||
@@ -37,8 +38,7 @@ function setLastSeenSubmissionsAt(iso: string): void {
|
|||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const diagram = useDiagramStore((s) => s.diagram)
|
const diagram = useDiagramStore((s) => s.diagram)
|
||||||
const access = useDiagramStore((s) => s.access)
|
const canEdit = useCanEdit()
|
||||||
const canEdit = access?.myPermission !== 'view'
|
|
||||||
const renameDiagram = useDiagramStore((s) => s.renameDiagram)
|
const renameDiagram = useDiagramStore((s) => s.renameDiagram)
|
||||||
const importDiagram = useDiagramStore((s) => s.importDiagram)
|
const importDiagram = useDiagramStore((s) => s.importDiagram)
|
||||||
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
@@ -92,13 +92,13 @@ export default function TopBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
|
<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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-indigo-700">Diagrav</span>
|
<span className="shrink-0 text-sm font-semibold text-indigo-700">Diagrav</span>
|
||||||
<input
|
<input
|
||||||
value={diagram.name}
|
value={diagram.name}
|
||||||
onChange={(e) => renameDiagram(e.target.value)}
|
onChange={(e) => renameDiagram(e.target.value)}
|
||||||
disabled={!canEdit}
|
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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -108,6 +108,13 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
Diagrams
|
Diagrams
|
||||||
</button>
|
</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">
|
<DropdownMenu label="Diagram">
|
||||||
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
|
||||||
@@ -166,6 +173,7 @@ export default function TopBar() {
|
|||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenu label={username ?? 'Account'} align="right">
|
<DropdownMenu label={username ?? 'Account'} align="right">
|
||||||
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||||
|
|
||||||
/** Shown when the current user only has view access to the open diagram
|
/** Shown whenever the canvas/inspectors have disabled their edit controls
|
||||||
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and
|
* (see useCanEdit) — either because this collaborator only has view access
|
||||||
* inspectors disable their edit controls in this case too (see
|
* to the diagram (organized-ideas.md §8's per-collaborator permissions), or
|
||||||
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that
|
* because the viewport is mobile-width, where editing isn't a realistic
|
||||||
* visible instead of leaving it to be discovered by a control not working. */
|
* 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() {
|
export default function ViewOnlyBanner() {
|
||||||
const access = useDiagramStore((s) => s.access)
|
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 (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
|
<div
|
||||||
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
|
className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center px-3 ${
|
||||||
View only — you don't have edit access to this diagram.
|
// 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../state/authStore'
|
|||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import { useCanEdit } from '../../hooks/useCanEdit'
|
||||||
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
|
||||||
import Chevron from '../common/Chevron'
|
import Chevron from '../common/Chevron'
|
||||||
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
|
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
|
||||||
@@ -55,8 +56,7 @@ function sortGroupsByLabel<T extends { key: string; label: string }>(groups: T[]
|
|||||||
|
|
||||||
export default function DevicePalette() {
|
export default function DevicePalette() {
|
||||||
const diagram = useDiagramStore((s) => s.diagram)
|
const diagram = useDiagramStore((s) => s.diagram)
|
||||||
const access = useDiagramStore((s) => s.access)
|
const canEdit = useCanEdit()
|
||||||
const canEdit = access?.myPermission !== 'view'
|
|
||||||
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
|
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
||||||
@@ -153,9 +153,23 @@ export default function DevicePalette() {
|
|||||||
|
|
||||||
const handleQuickAdd = (template: DeviceTemplate) => {
|
const handleQuickAdd = (template: DeviceTemplate) => {
|
||||||
if (!canEdit) return
|
if (!canEdit) return
|
||||||
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
|
// Tiled placement so repeated quick-adds land visibly apart rather than
|
||||||
const offset = (diagram.devices.length % 8) * 24
|
// overlapping — a compact device node is ~224px wide, expanded ~288px,
|
||||||
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
|
// 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) => {
|
const handleDelete = (template: DeviceTemplate) => {
|
||||||
@@ -236,7 +250,7 @@ export default function DevicePalette() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
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
|
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
if (!url || !anonKey) {
|
if (!rawUrl || !anonKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Copy .env.example to .env.local and fill them in ' +
|
'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).',
|
'(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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,9 @@
|
|||||||
// Deploys the built Vite app (dist/) as an assets-only Cloudflare Worker.
|
// 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.
|
// No server-side code — this is purely static hosting for app.diagrav.com.
|
||||||
// Build first (with production VITE_* env vars baked in), then:
|
// Build first (with the right VITE_* env vars baked in for the target
|
||||||
// npx wrangler deploy --config wrangler.app.jsonc
|
// 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",
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
"name": "diagrav-app",
|
"name": "diagrav-app",
|
||||||
@@ -15,5 +17,21 @@
|
|||||||
"pattern": "app.diagrav.com",
|
"pattern": "app.diagrav.com",
|
||||||
"custom_domain": true
|
"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
@@ -1,7 +1,8 @@
|
|||||||
// Deploys the hand-written marketing site (site/) as an assets-only
|
// Deploys the hand-written marketing site (site/) as an assets-only
|
||||||
// Cloudflare Worker for the bare diagrav.com domain. No build step —
|
// Cloudflare Worker for the bare diagrav.com domain. No build step —
|
||||||
// serves the folder as-is:
|
// 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",
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
"name": "diagrav-site",
|
"name": "diagrav-site",
|
||||||
@@ -15,5 +16,18 @@
|
|||||||
"pattern": "diagrav.com",
|
"pattern": "diagrav.com",
|
||||||
"custom_domain": true
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user