Compare commits

..
34 Commits
Author SHA1 Message Date
aarbit e4c36e2960 Merge pull request 'Add mobile view-only support + Woodpecker CI/CD pipeline' (#1) from mobile-view-only into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
Reviewed-on: #1
2026-09-29 21:55:51 +00:00
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
aarbitandClaude Sonnet 5 3344f89a8a Fix device palette rows squeezing the name for hidden action buttons
The hover-reveal button row used opacity, not display, so it always
reserved its full width even while invisible — squeezing long names down
to a few characters at rest, worst for an Admin's 5-button rows. Switched
to hidden/group-hover:flex so idle rows show the full name, and made the
row flex-wrap so a crowded button set drops to its own line on hover
instead of squeezing the name further.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:31:56 -05:00
aarbitandClaude Sonnet 5 0a7f2f7fee Deploy to Cloudflare Workers and rebrand to Diagrav
Adds Workers + static-assets configs for the app (app.diagrav.com) and a
separate marketing/landing site (diagrav.com), plus the deployment runbook
used to stand up Supabase Cloud, Google OAuth, Resend SMTP, and Cloudflare
DNS/custom domains. Renames the app from "AV Planner" to "Diagrav" to match
the new domain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:05:59 -05:00
aarbitandClaude Sonnet 5 a5c2ad87e1 Rebuild the connector catalog with gendered Male/Female port types
Replaces the old ungendered catalog (plain XLR, TS/TRS 1/4", Speakon
NL2/4/8, etc.) with a full Male/Female-aware set covering audio, video,
network/data, USB (with Host/Device role split for USB-C), power, RF coax,
and fiber connectors, plus real-world bridging/adapter cables — enough to
model gender benders and specify exact cable types in the BOM instead of
just a connector family. The catalog now lives entirely in the DB (seeded
via supabase/seed.sql, captured with the new export-seed.mjs) rather than
partly hardcoded in domain/library.ts, which is now unused.

Existing seeded diagrams/devices were intentionally not migrated to the
new port types — there was nothing important in them yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:05:41 -05:00
aarbitandClaude Sonnet 5 6cdfde822d Add site-wide announcements, account migration, and profile self-service
Announcements: a Super Admin (or an Admin individually flagged via
profiles.can_post_announcements) can post/retire a site-wide banner.
Account migration: a Super Admin can move a locked-out user's diagrams,
private catalog entries, and submissions to another account, with a
migration-history log; ProfileModal adds the self-service half (link a new
Google identity via Supabase manual linking, then unlink the old one,
while signed in as the account being migrated). Also reworks the top bar's
flat button row into grouped dropdown menus (Diagram / Admin / Account) now
that there are enough entries to need it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:05:11 -05:00
aarbitandClaude Sonnet 5 143a96aed1 Add collapsible device nodes, cable bundling, and aggregated edges
Canvas devices can now collapse to a compact per-direction-group view; when
collapsed, cables between the same two devices of the same type merge into
one dashed "virtual" edge with a ×N marker so a busy rig doesn't turn into
a wall of parallel lines. Adds CableBundle so several real Connections
(a stereo RCA pair, a snake's channels) can be grouped and bought as one
BOM line while keeping each leg's own family-checked cable type intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:37 -05:00
aarbitandClaude Sonnet 5 29c3ed46a3 Add manufacturer catalog, category/manufacturer library management, and built-in cable flag
Normalizes manufacturer as a shared catalog entity (like device categories)
instead of free text on each device, giving the admin duplicate-detection
nudge a reliable signal. Adds full CRUD (including Admin direct-publish,
bypassing the submission queue) for categories, manufacturers, port types,
and cable types, plus a Categories & Manufacturers library modal and a
browse-by-manufacturer/search view in the device palette. Adds
Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB
lead, a budget AVR's power cord) can be flagged and excluded from the BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:05 -05:00
aarbitandClaude Sonnet 5 00777077c2 Grant table privileges to anon/authenticated/service_role
RLS policies alone don't grant table-level access in Postgres — missing
GRANTs caused "permission denied for table profiles" in production even
though the RLS policies were correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 09:42:36 -05:00
aarbitandClaude Sonnet 5 bcc1dbc284 Fix production auth bugs: swallowed error message, dev hint leak
CompleteProfileScreen threw a raw PostgrestError (not an Error instance),
so `err instanceof Error` was false and the real message got swallowed.
LoginScreen's Mailpit hint was showing in production; gated behind DEV.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 09:42:26 -05:00
aarbit 26f41b3d4a Rename Project to Diagram throughout the app
Per organized-ideas.md §8: the storage layer (DiagramRepository etc.) was
already renamed in an earlier phase; this finishes it everywhere else.

- domain/types.ts: Project -> Diagram. domain/project.ts -> domain/diagram.ts
  (createEmptyProject -> createEmptyDiagram, default name "Untitled Diagram").
- domain/compatibility.ts, domain/bom.ts: Project param/type -> Diagram.
- data/exportImport.ts: ProjectImportError -> DiagramImportError,
  projectToJson/downloadProjectFile/readProjectFile/normalizeProject -> their
  Diagram equivalents.
- state/projectStore.ts -> state/diagramStore.ts: useProjectStore ->
  useDiagramStore, the `project` field -> `diagram`, newProject/renameProject/
  importProject/applyRestoredProject -> *Diagram, restoredProjectUpdatedAt ->
  restoredDiagramUpdatedAt.
- Every component updated to match, compiler-guided (tsc -b enumerated each
  remaining call site after the core rename, the same approach used for the
  earlier catalog-parameter refactor).
- README updated for the terminology, and to match the repository class
  names (which had already been renamed but the README hadn't caught up).

No backend/schema changes: the JSON shape stored in diagrams.data never
changed, only TypeScript-side identifiers, so existing diagrams are
unaffected. One SQL comment fixed for accuracy (no migration needed).

Verified: tsc -b and oxlint clean; grepped src/ for any remaining
Project/project reference (none) after the sweep.
2026-09-11 12:10:11 -05:00
aarbit cbbe96f575 Add BOM cable length sub-grouping
Per organized-ideas.md §7: cables of the same type sharing the same length
now get their own subtotal (count + total length + cost), revealed by
expanding that cable type's row rather than only showing one blended total.
Closes out the last open item in §7 — cost input, per-diagram override, and
the grand total were already in place.

- domain/bom.ts: computeBom groups each cable type's connections by
  distance too (a separate bucket for connections with no length entered),
  sorted shortest first.
- BomPanel: a chevron toggles the breakdown, only shown when a cable type
  actually has more than one distinct length group (nothing to reveal
  otherwise).

Verified: tsc -b and oxlint clean. No backend involved.
2026-09-11 11:54:33 -05:00
aarbit 4b757e89f5 Prevent sharing a diagram with yourself
RLS is the real guard (diagram_collaborators_insert/update now reject
user_id = the diagram's owner, regardless of who's performing the write —
covers a Super Admin acting on someone else's diagram too, not just the
normal owner path); the client-side check in
SupabaseDiagramCollaboratorRepository.add is just there to surface a
friendly message instead of the raw 42501.

Verified: tsc -b and oxlint clean; supabase db reset + 53/53 pgTAP tests
pass (1 new test).
2026-09-11 11:42:07 -05:00
aarbit dea26f7ee8 Add diagram sharing/collaborators, version history, and view-only lockdown
Per organized-ideas.md §8. Backend tables/RLS (diagrams, diagram_collaborators,
diagram_snapshots) already existed from an earlier phase — this is the
frontend for them, plus two small backend additions.

Backend (supabase/migrations/20260913000000_diagram_sharing.sql):
- find_user_id_by_username(text): lets any authenticated user resolve a
  username to an id for "share with @username" — unlike general profile
  browsing (blocked by profiles_select_self_or_super_admin), a username is
  meant to be a shareable handle, so this is deliberately not gated.
- diagram_collaborator_usernames / diagram_snapshot_saved_by_usernames:
  same pattern as the admin-review-queue phase's submitter-username
  lookup — batched per diagram, gated to "can you see this diagram at all"
  (reusing diagrams_select's own helper functions).
- prune_diagram_snapshots trigger: keeps the 50 most recent snapshots per
  diagram, enforced at write time rather than a scheduled job (diagram_
  snapshots has no update/delete policy for regular users at all).
- 14 new pgTAP tests (52/52 total).

Frontend:
- DiagramCollaboratorRepository/store + DiagramSharingModal: add/remove
  collaborators by username, per-person view/edit permission, owner-only
  controls.
- DiagramSnapshotRepository/store + VersionHistoryModal (its own top-bar
  button, not nested under Share — moved there after review): periodic
  checkpoints (one per 5 min of active editing) written as a side effect
  of normal saves, list + restore.
- Restore's duplicate-snapshot problem: repeatedly jumping between old
  versions without editing in between was writing a near-duplicate safety
  snapshot on every jump. Fixed by having projectStore track which
  snapshot the diagram was last restored from and its updatedAt at that
  moment (touch() always advances updatedAt on a genuine edit) — a restore
  skips the safety snapshot when nothing has changed since the last one,
  and the tracking clears on any real edit so in-progress work stays
  protected.
- DiagramRepository gains getAccess() (owner id + your own permission for
  the open diagram) — surfaced in projectStore as `access`.
- View-only enforcement: FlowCanvas disables drag/connect/drop
  (nodesDraggable/nodesConnectable + guarded handlers), DeviceInspector/
  ConnectionInspector wrap their controls in a disabled <fieldset>,
  DevicePalette disables adding devices to the canvas, TopBar disables the
  rename field, and a ViewOnlyBanner makes the restriction visible instead
  of leaving a collaborator to discover it as controls that just don't
  work. Autosave itself also refuses to write for a view-only user, as a
  backstop behind the UI-level lockdown.

Verified: tsc -b and oxlint clean; supabase db reset + 52/52 pgTAP tests
pass; confirmed find_user_id_by_username works through the real REST API
via a live curl call (signup, confirm, resolve). Manually tested two-
account sharing (view vs. edit), restoring history, and the duplicate-
snapshot fix.
2026-09-11 11:28:58 -05:00
aarbit 4c45b5afa7 Add Roles & Admin/Super-Admin interface
Per organized-ideas.md §6: role assignment, account ban/unban/delete, and
direct Admin/Super-Admin CRUD of public catalog entries outside the
submission workflow.

Backend:
- list_users_for_admin(): Super-Admin-gated SECURITY DEFINER function
  joining profiles + auth.users (username, email, role, banned_until) —
  auth.users isn't exposed through PostgREST, so this is the only way to
  list accounts at all.
- New Edge Function admin-user-action (ban/unban/delete), using
  @supabase/server's `auth: 'user'` mode to verify the caller's JWT, then
  Supabase Auth's Admin API for the actual mutation. This is deliberately
  an Edge Function rather than a Postgres function like everything else in
  this codebase: touching auth.users needs the Admin API, the stable
  documented interface, not a direct write to a schema Supabase manages
  internally. Self-action guard; verify_jwt = true at the gateway on top of
  the function's own JWT verification.
- 5 new pgTAP tests (43/43 total) for list_users_for_admin (Super-Admin-only,
  even regular Admins get 42501).
- CatalogRepository gains admin* methods (direct edit of a public port/cable/
  device entry, plus adminUnpublish which flips is_public rather than
  deleting) — the update methods were already ownership-agnostic (RLS's
  is_admin() clause is what actually permits it), so these are thin aliases,
  not duplicated logic.

Frontend:
- authStore/AdminUserRepository: minimal role plumbing, shared UserRole type.
- adminUserStore + AdminUsersModal: list/role-dropdown/ban/unban/delete,
  gated to Super Admin only via a new "Manage Users" TopBar button.
- PortTypeManager/CableTypeManager/DevicePalette: built-in entries now show
  direct "Edit"/"Unpublish" for Admins (regular Admin included, per §6's
  capability table — not Super-Admin-exclusive) instead of "Suggest edit";
  unpublish reuses the review-queue's impact-check RPC before confirming.
- DeviceTemplateEditor gains an `adminMode` save path alongside its existing
  submissionMode/resubmitId ones.

Verified: tsc -b and oxlint clean; supabase db reset + 43/43 pgTAP tests
pass; confirmed both new privileged endpoints (the SQL function and the
Edge Function) actually work through the real REST API via live curl
calls — signup, email confirm, role promotion, ban/unban/delete round
trips, self-action guard, non-super-admin rejection, and verify_jwt=true
compatibility all exercised directly, not just asserted.
2026-09-08 16:01:01 -05:00
aarbit 1f8d49345e Add the Admin review queue
Lets an Admin/Super-Admin review pending catalog submissions and approve
(in place, same id) or reject (with a required reason) them, per
organized-ideas.md §3/§9.

Backend (supabase/migrations/20260910010000_admin_review_queue.sql):
- Per-user pending-submission cap (10), enforced in catalog_submissions'
  insert policy rather than trusted to the client.
- catalog_entity_usage_impact(entity_type, entity_id): a SECURITY DEFINER,
  admin-gated aggregate function answering "how many diagrams reference
  this, and a short sample" by scanning diagrams.data JSONB — never raw
  diagram content, and available to regular Admins even though they don't
  otherwise have diagram visibility (only Super Admins do, per §6).
- catalog_submission_submitters(ids[]): same admin-gated pattern, batched,
  so the queue can show who submitted something without opening general
  profile browsing to regular Admins.
- Follow-up migration: a rejected submission had no way out (the delete
  policy only allowed withdrawing 'pending') — extended to allow 'rejected'
  too, so a submitter can dismiss one they don't intend to revise.
- 12 new pgTAP tests (38/38 total) covering the cap, both privileged
  functions (including the non-admin-gets-rejected case), and withdrawing
  pending vs. rejected submissions.

Frontend:
- authStore: minimal role awareness, replacing TopBar's local username
  fetch, used to gate the Review Queue UI.
- AdminSubmissionRepository/SupabaseAdminSubmissionRepository +
  adminReviewStore: list all submissions, approve/reject, usage impact,
  submitter usernames.
- AdminReviewModal: per-submission diff view (current vs. proposed, both
  row-shaped via the existing catalog<->row mappers), a duplicate-detection
  nudge (Levenshtein distance against existing public device names) for
  new device submissions, and an inline impact-check for edits to
  already-public entries before approving.
- TopBar: role-gated "Review Queue" button with a pending-count badge; "My
  Submissions" gets an unseen-outcome badge (localStorage-tracked, like the
  existing hidden-template preference) so a submitter notices a decision
  without having to keep reopening the modal.
- Deliberately deferred: the site-wide announcement banner (its own
  follow-up, per discussion) and the Admin/Super-Admin role-assignment UI
  (§9's later phase — becoming an Admin locally still means setting
  profiles.role via SQL/Studio).

Verified: tsc -b and oxlint clean; supabase db reset + 38/38 pgTAP tests
pass; confirmed the two new RPC functions are actually reachable through
PostgREST (not just raw SQL) via a live curl call; manually tested
submit -> review -> approve/reject -> (for rejected) dismiss end to end.
2026-09-08 13:01:56 -05:00
aarbit a0c598cb1b Add the user-submission flow for the catalog
Lets users submit a private catalog entry (port type, cable type, device
template) for promotion to the public catalog, or suggest an edit to an
existing public entry — both go into the catalog_submissions review queue
per organized-ideas.md §3. No Admin review UI yet (next sub-phase); this
covers the submitter's side only.

- data/SubmissionRepository + SupabaseSubmissionRepository: submit,
  resubmit, withdraw, list-mine, backed by the existing catalog_submissions
  RLS policies (no schema changes needed).
- data/catalogRowMapping.ts: shared domain<->row mappers, in both
  directions, so a submission's proposed_data is always shaped like the
  underlying table row (what an eventual admin-approval would write
  directly) and can be turned back into form-editable fields for revision.
- state/submissionStore.ts: mySubmissions + submit/resubmit/withdraw, plus
  syncProposedData — called from catalogStore's updateCustom* actions so a
  submission about your own still-private entry never goes stale relative
  to it (edits from the library and from My Submissions are the same
  action and always agree).
- UI: "Submit"/"Suggest edit" wired into PortTypeManager, CableTypeManager,
  DevicePalette/DeviceTemplateEditor; new MySubmissionsModal (opened from
  TopBar, with a pending-count badge) shows status, rejection reasons, and
  lets you edit/resubmit or withdraw.
- Deliberately deferred: device_category submissions (no listing UI to
  hang a button on yet) and the normalized manufacturer catalog.

Verified: tsc -b and oxlint clean; supabase db reset + 23/23 pgTAP RLS
tests still pass (no schema changes this round); manually tested submit,
suggest-edit, edit-from-either-side sync, reject/resubmit, and withdraw.
2026-09-08 10:37:38 -05:00
aarbit a03987c36b Show the signed-in user's username in the top bar 2026-09-07 23:46:48 -05:00
aarbit b97777444a Wire the app to the shared Supabase catalog (public catalog read integration)
Moves port/cable/category/device-template data from per-diagram embedded
storage to the global public/private catalog backed by Supabase, per
organized-ideas.md's "live reference, not snapshot" decision.

- domain/types.ts, project.ts, compatibility.ts, bom.ts: lookup functions
  now take an explicit Catalog parameter instead of deriving data from
  Project — Project is reduced to just diagram-scoped fields.
- New CatalogRepository/SupabaseCatalogRepository (mirrors the
  DiagramRepository pattern) and catalogStore.ts, replacing the
  catalog-related actions that used to live in projectStore.
- device_templates gets a plain-text manufacturer column for now (the
  normalized manufacturer catalog from organized-ideas.md §3 is its own
  future pass, not blocking this one).
- domain/library.ts is no longer imported by the app — it's now only the
  source scripts/generate-seed.mjs reads to produce supabase/seed.sql.
- Swept every UI call site via tsc -b until clean; oxlint clean; 23/23
  pgTAP RLS tests still passing after a `supabase db reset`.
2026-09-07 23:42:00 -05:00
aarbitandClaude Sonnet 5 93cb4a9617 Seed the public catalog from domain/library.ts
- Catalog table ids (device_categories, port_types, cable_types,
  device_templates) switch from uuid to text so the existing stable,
  human-readable ids (pt-hdmi, dt-display, ...) survive the move
  instead of every diagram's references silently orphaning.
- supabase/seed.sql is generated (scripts/generate-seed.mjs), not
  hand-written, so the seed data can't drift from the actual source of
  truth in domain/library.ts. Re-run the script after editing the
  built-in library.
- Also committing ideas.md/organized-ideas.md, which have been driving
  every backend decision this whole project but were never actually
  checked in.

RLS test suite re-run clean (23/23) after both the schema change and
the seed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-07 23:02:12 -05:00
aarbitandClaude Sonnet 5 2f1b83e0a9 Add multi-diagram management
- DiagramRepository interface redesigned around multiple diagrams:
  list()/loadById()/deleteById() replace the old single-diagram
  load()/clear(). Both implementations updated to match.
- SupabaseDiagramRepository.save() now does an explicit update-or-insert
  instead of a blind upsert, so owner_id is only ever set at creation --
  an upsert would resend it on every save and let whoever saves last
  silently reassign ownership. Not reachable yet (no collaborator UI),
  but a real landmine once diagram sharing (organized-ideas.md §8) lands,
  and cheap to avoid now.
- LocalStorageDiagramRepository now stores diagrams keyed by id (was a
  single fixed key), keeping it a genuine working fallback rather than
  a stale reference implementing an old interface.
- Store: loadInitialDiagram (renamed from loadFromStorage) opens the
  last diagram you had open (tracked in localStorage -- a UI
  preference, not app data), falling back to the most recently updated
  one, falling back to a fresh empty diagram. New actions:
  refreshDiagramList, switchToDiagram, deleteDiagram. newProject and
  importProject now persist immediately (not just via the debounced
  autosave) so a new/imported diagram shows up in the list right away;
  importProject also assigns a fresh id so it can't collide with an
  existing diagram.
- New DiagramManagerModal (list/open/delete/+New), opened from a
  "Diagrams" button in TopBar that replaces the old single-diagram
  "New" button and its now-unnecessary confirmation dialog -- nothing
  is lost by creating a new diagram anymore, since the old one stays
  saved and reachable from the list.

Verified insert/list/update(-preserves-owner)/loadById/delete against
the real local stack; RLS test suite still 23/23 after a fresh reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-07 21:19:24 -05:00
aarbitandClaude Sonnet 5 027455cc7d Add Google SSO
- Migration: profiles.username is now nullable -- Google's OAuth
  redirect can't collect a username up front the way the email/password
  signup form does, so a first-time Google sign-in's profile is created
  with no username.
- supabase/config.toml: [auth.external.google] enabled, credentials via
  env() substitution (SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID/_SECRET
  in .env.local, which the CLI auto-loads). skip_nonce_check is on,
  which Supabase's own docs call out as required for local sign-in.
- LoginScreen: "Continue with Google" alongside the existing
  email/password form.
- CompleteProfileScreen: one-time gate for a signed-in user with no
  username yet (i.e. first Google sign-in) -- same hard-gate spirit as
  email verification, nothing else is usable until a username is set.
- App.tsx now checks profiles.username after establishing a session and
  routes to CompleteProfileScreen before AppShell when it's missing.

RLS test suite re-run clean (23/23) after the schema change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-06 23:28:25 -05:00
aarbitandClaude Sonnet 5 5e8b09f6e2 Fix dev server not listening on 127.0.0.1
Vite's default host resolves "localhost" to the IPv6 loopback (::1)
on this machine, so it never actually bound 127.0.0.1 -- but that's
the exact address Supabase Auth's email confirmation links redirect
to (config.toml uses 127.0.0.1 throughout). Binding explicitly to
127.0.0.1 fixes confirmation links without needing to hand-edit them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-06 23:04:07 -05:00
aarbitandClaude Sonnet 5 1ae967c8a4 Wire the app to the Supabase backend, replacing localStorage-only
- DiagramRepository (renamed from ProjectRepository, per the
  organized-ideas.md §8 naming decision) now has a Supabase-backed
  implementation as the active repository. LocalStorageDiagramRepository
  stays in the codebase as a reference implementation / fallback, just
  no longer wired in. Only the storage layer's naming changed here --
  the domain type, store, and UI copy still say "Project"; that's a
  separate, larger mechanical rename tracked on its own.
- Minimal email/password auth gate (src/components/auth/LoginScreen.tsx)
  since Supabase RLS requires a real signed-in user to do anything --
  this is NOT the Phase 2 experience (Google SSO, polished signup),
  just enough of the same schema (username + email + password) to make
  the backend foundation usable end to end before that phase exists.
  Respects the hard email-verification gate from config.toml.
- .env.example documents the required VITE_SUPABASE_URL /
  VITE_SUPABASE_ANON_KEY (local dev values, not secrets); .env.local
  has the actual local values and is gitignored.

Verified end-to-end against the local stack: signup creates a
confirmed-pending user, the handle_new_user trigger creates their
profile, sign-in is blocked until confirmed, and a signed-in session
can upsert/read back its own diagram row exactly as the app's
save()/load() do it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-05 00:33:28 -05:00
aarbitandClaude Sonnet 5 8cea3f8b92 Add local Supabase backend foundation: schema, RLS, and RLS tests
- supabase/config.toml: local dev stack config, pinned to the app's
  fixed dev server port, email confirmation required (hard
  verification gate per organized-ideas.md).
- Initial schema migration: profiles/roles, the public/private
  catalog tables (manufacturers, device categories, port types, cable
  types, device templates + ports) with the shared is_public/owner_id
  RLS pattern, a generalized catalog_submissions review-queue table,
  and diagrams as JSONB documents (+ collaborators, snapshots) rather
  than fully normalized -- see the migration's header comment for why.
- pgTAP RLS test suite (23 assertions) covering catalog visibility and
  promotion-in-place, diagram owner/collaborator/admin/super-admin
  visibility and edit permissions, submission visibility, and role
  escalation. Caught and fixed a real infinite-recursion bug between
  the diagrams and diagram_collaborators policies before this ever
  touched real data.
- vite.config.ts: pinned dev server port so Supabase Auth's redirect
  allow-list doesn't silently break if Vite floats to another port.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-04 23:11:52 -05:00
aarbitandClaude Sonnet 5 e424e40f1b Simplify connection cost field label
Just "Cost", not "Cost override" -- there's no per-type default cost
set, and there isn't meant to be one right now (pricing varies too
much across cable grades), so "override" implied a relationship that
doesn't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-04 23:04:32 -05:00
aarbitandClaude Sonnet 5 e8654a39d6 Add BOM cost tracking
Suggested-default cost on CableType/DeviceTemplate, copied onto
placed Device instances (same pattern as ports) and overridable
per-connection/per-device in the diagram. BOM view shows per-line
and grand-total cost, excluding (and flagging) anything without a
computable cost rather than treating it as zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-04 15:55:57 -05:00
aarbitandClaude Sonnet 5 7cf786e107 Updates UI arrows to match look and feel
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-01 15:04:19 -05:00
125 changed files with 15309 additions and 1326 deletions
+12
View File
@@ -0,0 +1,12 @@
# Copy to .env.local (gitignored) and fill in. For local dev, run
# `npx supabase status` after `npx supabase start` to get these values —
# they're stable, well-known local dev credentials, not secrets.
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=
# Google OAuth credentials (real secrets — from your own Google Cloud
# project, never commit real values). Read by the Supabase CLI itself for
# supabase/config.toml's env() substitution, not by the frontend.
# Authorized redirect URI to set in Google Cloud: http://127.0.0.1:54321/auth/v1/callback
SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID=
SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET=
+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
+16 -13
View File
@@ -1,4 +1,4 @@
# AV Planner # Diagrav
Plan out AV/network installs: define devices and their ports, wire them together Plan out AV/network installs: define devices and their ports, wire them together
on a canvas, and get a bill of materials (devices + cables, with lengths) for on a canvas, and get a bill of materials (devices + cables, with lengths) for
@@ -18,13 +18,14 @@ src/
domain/ Framework-agnostic types + logic (compatibility rules, BOM math, domain/ Framework-agnostic types + logic (compatibility rules, BOM math,
the built-in connector/cable/device library). No React, no the built-in connector/cable/device library). No React, no
React Flow — safe to unit test or reuse from a future backend. React Flow — safe to unit test or reuse from a future backend.
data/ Storage abstraction. `ProjectRepository` is the interface the data/ Storage abstraction. `DiagramRepository` is the interface the
rest of the app codes against; `LocalStorageProjectRepository` rest of the app codes against; `SupabaseDiagramRepository` is
is the only implementation today. Swapping in a real backend the active implementation, with `LocalStorageDiagramRepository`
later (REST/GraphQL) means adding one new implementation of kept as a reference/fallback. Swapping storage means adding
that interface, not touching state/UI code. one new implementation of that interface, not touching
state/ Zustand store (`projectStore`) — the single source of truth for state/UI code.
the current project. Auto-saves to the repository (debounced) state/ Zustand store (`diagramStore`) — the single source of truth for
the current diagram. Auto-saves to the repository (debounced)
on every change. on every change.
components/ components/
canvas/ React Flow wiring surface: custom device node, custom cable canvas/ React Flow wiring surface: custom device node, custom cable
@@ -40,17 +41,19 @@ src/
- **PortType** — a connector kind (HDMI, XLR, Cat5e/6, ...). Two ports can be - **PortType** — a connector kind (HDMI, XLR, Cat5e/6, ...). Two ports can be
wired together only if their PortTypes share a compatibility `family`. wired together only if their PortTypes share a compatibility `family`.
- **DeviceTemplate** — a reusable device shape (its ports), shown in the - **DeviceTemplate** — a reusable device shape (its ports), shown in the
palette. Built-ins live in `domain/library.ts`; custom ones are saved on the palette. Built-ins live in `domain/library.ts` and seed the public catalog;
project. custom ones are your own private catalog entries (or submitted for public
review).
- **Device** — an instance placed on the canvas, with its own copy of ports - **Device** — an instance placed on the canvas, with its own copy of ports
(editing an instance never mutates its template). (editing an instance never mutates its template).
- **CableType** — a physical cable spec, scoped to a PortType family. - **CableType** — a physical cable spec, scoped to a PortType family.
- **Connection** — a wire between two ports, referencing a CableType and an - **Connection** — a wire between two ports, referencing a CableType and an
optional user-entered distance (there's no floor plan / scale model, so optional user-entered distance (there's no floor plan / scale model, so
length is a manual estimate per connection). length is a manual estimate per connection).
- **Project** — devices + connections + any custom library entries. This is - **Diagram** — devices + connections. This is the one object persisted
the one object persisted (auto-saved to `localStorage`, or explicitly (auto-saved to Supabase, or explicitly exported/imported as JSON); the
exported/imported as JSON). catalog (PortType/CableType/DeviceTemplate/category) is shared and global,
not part of any one diagram.
## Development ## Development
+190
View File
@@ -0,0 +1,190 @@
# Deployment & CI/CD planning notes
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 — **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)
already commits to this being provider-agnostic (Supabase Cloud, self-hosted
Supabase Docker stack, or export the Postgres DB elsewhere — no app code
changes needed either way).
To go live:
1. **Backend**: create a Supabase Cloud project, `supabase link` + `supabase db push`
to apply the 15 existing migrations, `supabase functions deploy admin-user-action`.
Set real Google OAuth credentials for the production redirect URL — right
now `supabase/config.toml` and `vite.config.ts` are hardcoded to
`127.0.0.1` with `skip_nonce_check = true` for local dev; this needs a
production config pass. Needs a real SMTP provider for auth emails
(Resend suggested in organized-ideas.md as the default) since Inbucket
(local fake mail catcher) doesn't exist in the cloud.
2. **Frontend**: `npm run build` → static `dist/`, deploy to any static host
(Vercel/Netlify/Cloudflare Pages, or self-hosted). Set
`VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` as build-time env vars
pointing at the real Supabase project (Vite bakes these in at build
time — not runtime-configurable without a refactor).
## Marketing site (`site/`)
A separate, informational "what is this thing" page lives in `site/` at the
repo root — plain hand-written HTML/CSS (`index.html` + `style.css` +
`images/`), no build step, not part of the Vite app or its `src/` tree at
all. Screenshots under `images/` are real captures from the app (a demo
DAWless rig wired up for the purpose), referenced with relative paths, so
the folder is fully self-contained.
Deploys the same way the app's frontend would, just simpler — no env vars to
bake in, no build command, just serve the static files as-is. Same free-tier
host options apply (Vercel/Netlify/Cloudflare Pages, or self-hosted).
**Domain purchased: `diagrav.com`.** The app was renamed from "AV Planner" to
**Diagrav** to match. The site's nav ("Log in" / "Sign up") and its closing
CTA ("Sign up free") now link to `https://app.diagrav.com/` — so the
subdomain-split option below is the working assumption baked into the site
as of now, even though neither `diagrav.com` nor `app.diagrav.com` is
actually deployed anywhere yet. Those links will 404 until the app is
actually live at that subdomain; treat this as a leaning, not a final
decision — swapping to the path-based option only means changing the few
`href`s in `site/index.html`, nothing structural.
- **Subdomain split (current assumption)**: bare `diagrav.com` for the
marketing site, `app.diagrav.com` for the actual tool — cleanest
separation, two hosting targets instead of one.
- **Same domain, different path** (marketing at `/`, app at `/app` or
similar) — one hosting target, but means the static host needs to route
`/` to `site/` and everything else to the Vite `dist/` output, which the
simple "just deploy `dist/`" flow above doesn't currently account for.
Whichever is chosen, this needs its own line in the CI/CD plan below
(currently only the app's frontend/backend deploy steps are scoped) — it's a
third deployable alongside "frontend" and "backend," not an afterthought
folded into the frontend step.
## Cost / free-tier findings
Provider: **Supabase**, since the app already codes directly against its
SDK/Auth/RLS model (`src/data/Supabase*.ts`) — switching providers would mean
rewriting those repositories.
Current Supabase Free plan limits (confirmed against supabase.com/pricing,
not from training data — re-verify if this doc is read much later):
| Resource | Free limit |
|---|---|
| Database size | 500 MB |
| Egress | 10 GB/mo (5 GB cached + 5 GB uncached) |
| Monthly active users | 50,000 |
| Edge Function invocations | 500,000/mo |
| File storage | 1 GB |
| Realtime concurrent connections | 200 |
| Inactivity | paused after 1 week idle, max 2 active projects |
**Conclusion: this app is storage-bound, not bandwidth-bound.** File storage,
realtime, and edge function limits are all non-issues (no file uploads, no
`supabase.channel(...)` usage anywhere in `src/`, and the one edge function
is admin-only/rare). Egress is generous relative to what this app transfers
per session. The one limit likely to actually bite is **database size**
(500 MB) — driven less by user-count/traffic than by long-term accumulation
in `diagrams` + `diagram_snapshots`.
**Specific risk found**: `SupabaseDiagramRepository.ts` (`maybeWriteSnapshot`,
~line 70-93) writes a new version-history snapshot row every 5 minutes of
active editing, with **no retention/expiry** — they accumulate indefinitely.
A user who edits diagrams regularly for months will grow the DB more than a
burst of new signups would. **Suggested follow-up (not yet done): add
snapshot retention** — e.g. keep the last N snapshots per diagram, or prune
anything older than ~90 days. This is the highest-leverage lever for keeping
DB size (and therefore cost/free-tier headroom) under control.
Rough scale ballpark discussed: solo/small-team use won't come close to any
limit for years. Somewhere in the ~300–1,500 regular-active-user range with
several diagrams each is roughly where the 500MB DB cap would start to bite,
well before the 10GB egress cap or 50k MAU cap would matter. Treat this as a
rough estimate, not a measured number — worth checking real diagram JSON
size in Studio once there's real usage.
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: 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. `.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.
**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.
**Two fully separate environments exist, each with its own everything** (no
shared data or infra between them, or with 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` |
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).
**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)
- 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.
- The production `supabase_db_password` gap noted above.
+65
View File
@@ -0,0 +1,65 @@
* Remove hard coded devices
* Create back end with storage
* Determine relational or nosql for storage
* Users:
* Types:
* regular - has own account. can CRUD own devices. can create own diagrams. can use public devices in diagrams. can submit a new device for review or suggestion for device modification.
* Admin - same as regular user, but additionally: can view/approve devices and suggestions
* Super Admin - same as Admin, but additionally: can CRUD users and user devices/diagrams, etc. Has full access to CRUD all data
# Device Compact view
* Devices should have a compact state that doesn't show individual connection points, but rather an aggregate for inputs/outputs/bidirectional. The device should show connected/not connected numbers for inputs/outputs/bidirectional so the user can see what's still got open ports
* a device can be expanded to show the full individual ports
* The compact view should show the name of the device, but not the make/model
* There should be a virtual cable for each type of connection to another device, but all similar connections (e.g. inputs) to the same device should be shown as a single virtual cable
* aggregated cables should have a special name or tag or something that lets the user know it's an aggegate.
# Device Expanded view
* Devices should have an expanded view that shows all connection points with name and type, the device name, make and model, and have individual virtual cables for each connection point.
* There should be two different device menus. One is for public devices. One is for user defined devices.
# Public device menu
* Should be searchable, and have multiple views: category>manufacturer and manufacturer>category
* These device definitions cannot be directly modified by a regular user. Instances of them can be used by regular users in their own diagrams.
* regular users can submit modified versions for review (to help make the device definitions better for all users)
# User device menu
* should be searchable and viewed by device category (adapter, effect, mixer, etc)
* These are user defined devices.
* They are only viewable to the user that created them (and admins, but not other regular users)
* user devices can be submitted for adding as a public device.
* Port types and cable types should work effectively like devices: there's a public set and then users can create their own as needed, and submit for addition to the public set.
* There should be an admin interface for admins and super admins
# Admin/super admin interface
* should allow admins to view and approve/reject submissions for devices/ports/cables
* should allow admins to CRUD public devices/ports/cables
* should allow super admins to CRUD all devices/ports/cables
* should allow super admins to view users diagrams
* should allow super admins to CRUD users diagrams
* should allow super admins to CRUD user accounts
* should not allow anyone to see/edit a user's password.
# Bill of Materials view
* Cables of the same type should be grouped together, with individual cable lengths of that type available to see when expanded with a chevron.
* Cables of the same type and length should show their own count and total length
* User should be able to input cost for each cable type and length, and for each device in the diagram
* a cost total should be available to view
# Diagram menu
* a user should be able to see a list of all their own saved diagrams, and should be able to load/delete individual ones
# user signup
* users can use either google single sign in or manually sign up
* if using google, they only need to provide a user name, we'll gather email from Google
* if manual sign up, the user will provide a user name, password, and (valid) email
* determine best option for sending emails
* username must be unique
# user sign in
* users can use either google sign in or manually enter username/password
* determine possible path for allowing users to change their email
* determine possible path for migrating a user account from one google account to another
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AV Planner</title> <title>Diagrav</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+149
View File
@@ -0,0 +1,149 @@
# AV Planner — Robustness Plan
Organized from `ideas.md`, plus decisions made while working through it together on 2026-09-04. Decided items are marked **Decided**; items I'm recommending but that are still yours to confirm are marked **Recommended**; things nobody's answered yet are marked **Open Question** and are also collected in one list at the end.
## 0. Guiding principle: local-first, no premature lock-in
Stated explicitly during planning: this needs to run **entirely locally** during development, with zero cost and zero cloud dependency, while still getting BaaS-level convenience (auth, roles, storage) instead of hand-rolling all of it. The choice to go to a specific cloud vendor is deferred until the app is actually ready for production. Every recommendation below is filtered through that constraint — anything that only runs as someone else's hosted service is out.
- **Decided: this is a development-time constraint, not a shipped product feature.** The deployed app is a normal networked web app that talks to a real backend — no offline mode, local cache/sync layer, or conflict resolution for disconnected use is in scope. "Local-first" describes how *you* build it, not a capability end users get.
---
## 1. Backend & Data Architecture
- **Decided: Supabase, self-hosted.** Postgres underneath, with built-in auth (Google OAuth included), storage, and row-level security (RLS) that maps cleanly onto the regular/admin/super-admin model in section 2.
- **Decided (resolves "determine relational or NoSQL"): relational / Postgres.** Falls directly out of the Supabase choice, and fits this data anyway — devices, ports, cables, diagrams, and users all have real relationships and referential integrity matters (e.g. a diagram shouldn't reference a device type that's been deleted).
- **Local dev:** the Supabase CLI (`supabase start`) spins up the full stack — Postgres, Auth (GoTrue), REST API (PostgREST), Storage, Realtime, and a local Studio admin UI — in Docker, on your machine, for free. No account or network access needed to develop.
- **No lock-in path:** because it's just Postgres + open-source services under the hood, "going to production" later is a real choice, not a foregone conclusion — options are Supabase Cloud (managed, easiest), self-hosting the same Docker stack on your own server, or exporting the Postgres database to any other Postgres host. None of the application code needs to change based on which of these you pick.
- **Frontend integration:** the app already isolates storage behind a `ProjectRepository` interface (see `src/data/`), specifically so a real backend could be swapped in without touching UI/state code. A Supabase-backed repository implementation slots into that seam directly.
- **Revised: diagrams reference catalog entries by id (live reference), not a full embedded snapshot.** Superseded from an earlier pass — see the discussion recorded in §3, which walked through concrete scenarios and decided live references are net better: they let your own catalog edits (e.g. a price change on a custom cable type) propagate across all your diagrams automatically, and let cosmetic fixes to public entries reach diagrams that already used them, at the cost of a public entry's *compatibility-affecting* fields being able to affect existing diagrams if edited carelessly — which §3's impact-check-before-editing and public-entries-are-never-deleted decisions specifically guard against. Placing a device still copies its *ports* (fresh ids, independent of the template afterward) — that part of the original pattern is unchanged; it's specifically port/cable/category/manufacturer *type* definitions that are referenced live, not copied.
- **Decided: schema changes go through git-tracked migrations via the Supabase CLI**, not ad hoc edits in the local Studio UI — keeps local dev and eventual production reproducible and reviewable, same as any other code change.
- **Decided: automated tests specifically for the RLS policies.** These are the actual security boundary once roles matter, and they're easy to get subtly wrong in a way that fails open (exposes data that shouldn't be visible) rather than fails loud — worth explicit test coverage even if the rest of the app is tested more lightly.
## 2. Authentication & Users
**Roles** (restated as a table):
| Role | Can do |
|---|---|
| Regular | CRUD own devices; create/own diagrams; use public devices in diagrams; submit new devices or edits to existing ones for review |
| Admin | Everything Regular can, plus: view/approve/reject device & port/cable-type submissions |
| Super Admin | Everything Admin can, plus: CRUD *all* devices/ports/cables (public or anyone's); view and CRUD *any* user's diagrams; CRUD user accounts. Cannot see/edit a user's password (Supabase Auth stores credentials itself — the app never has access to raw or hashed passwords). |
- **Recommended:** enforce this at the database layer with Postgres row-level security policies keyed off role, not just in application code — it's the natural fit for Supabase and means a bug in the UI can't accidentally expose another user's data.
- **Signup/signin:** Google SSO (username collected up front, email pulled from Google) or manual signup (username, password, verified email) — both handled by Supabase Auth directly, so no custom password-hashing/session code needed. Username must be unique — enforced with a DB unique constraint.
- **Decided: accounts auto-link on matching verified email.** If someone signs up manually with an email and later signs in with Google using that same address, treat it as one account rather than creating a second, confusing one — matches the far more common real-world case (same person) over the rarer risk case.
- **Decided: baseline abuse prevention from the start**, not deferred — basic rate limiting on auth (leaning on what Supabase Auth provides out of the box) plus a soft cap on pending submissions per user (see §3). Cheap to build in now, expensive to retrofit once there's real usage and real abuse; self-hosting means infra abuse cost lands on you directly, not a SaaS vendor.
- **Decided: email verification is a hard gate.** A manually-signed-up user must verify their email before creating diagrams, submitting devices, or doing anything else — no unverified account accumulates real data. Google-SSO users are implicitly verified via Google, so this only affects the manual-signup path.
### Determine: best option for sending emails
**Recommended.** Auth emails (signup confirmation, password reset) go through Supabase Auth's SMTP integration — Supabase doesn't send mail itself, it relays through whatever SMTP server you configure, which keeps this swappable-by-design (change SMTP credentials, not code). For local dev, the Supabase CLI includes **Inbucket**, a fake mail catcher with a web UI, so you can test signup/reset flows locally without sending real email or paying anything. For production, plug in any standard transactional-email provider over SMTP — **Resend** is a reasonable default (generous free tier, simple setup), with Postmark or Amazon SES as alternatives if deliverability or cost at higher volume becomes a concern. This is a config change, not an architecture decision, and doesn't need to be locked in now.
### Determine: path for allowing users to change their email
**Recommended.** Supabase Auth has this built in (`updateUser({ email })`): the user requests the change, a confirmation link is sent to the *new* address, and the email only actually updates once that's confirmed. No custom flow to build — just wire up the UI for it. Username stays separate from email and keeps its own uniqueness check.
### Determine: path for migrating a user account from one Google account to another
**Recommended**, two-tier:
1. **Self-service (has access to both accounts):** Supabase Auth supports linking a second OAuth identity to an existing account (`linkIdentity`). The user signs in with the old Google account, links the new one, then unlinks the old — same internal account/UUID throughout, so all owned diagrams and devices carry over with zero data migration.
2. **Lost access to the old account:** needs a human — a Super Admin tool to reassign an account's linked identity or merge two accounts (transfer diagrams/devices from A to B). This is an edge case, not needed for MVP; flagging it now so the Super Admin "CRUD user accounts" capability is built with this in mind rather than needing rework later.
---
## 3. Device / Port / Cable Type Library (Public vs. User-Defined)
- Devices, port types, and cable types all follow the **same pattern**: a public, curated catalog plus each user's own private catalog.
- Public catalog entries can't be edited directly by regular users; they can only submit a new entry or a suggested edit to an existing one, which goes into a review queue for Admins.
- A user's own catalog entries are visible only to that user (and Admins/Super Admins), and can be submitted for promotion to the public catalog.
- **Seeding:** the app's current hardcoded libraries (`domain/library.ts` — port types, cable types, device templates, categories) become the initial seed data for the public catalog once the backend exists, rather than being thrown away.
- **Decided: review/approval experience.** An Admin reviewing a submission sees a **diff against the current public entry** (not just the raw submitted version), and the submitter is **notified either way** — approved or rejected, with a reason attached on rejection rather than a silent disappearance.
- **Decided: notify in-app, not just by email.** A bell icon/activity feed surfaces submission outcomes for submitters and a pending count for Admins — email is fine as the durable record, but a faster in-app loop matters for an Admin actively working through the queue.
- **Decided: a light duplicate-detection nudge for Admins** reviewing new device submissions — fuzzy-match the proposed name against existing public entries and flag likely duplicates, so the catalog doesn't slowly fill with near-identical entries as more people submit.
- **Decided: a soft cap on pending submissions per user** (exact number TBD when this is built), as part of the baseline abuse prevention in §2 — stops one user from flooding the review queue.
- **Decided: approval promotes the submitter's entry in place**, rather than creating a separate new public entry. Same id, just flips from private to public — the submitter's existing diagrams (which already snapshotted it, per §1) don't need re-pointing, and there's no leftover duplicate private copy sitting alongside the new public one.
- **Decided: rejected submissions stay editable for resubmission**, not deleted or dead-ended. The submitter can revise based on the Admin's stated reason and resubmit the same submission rather than starting over from scratch.
- **Decided: "manufacturer" becomes a normalized, submittable catalog** — the same public/user-defined pattern already used for device categories, rather than free text on each device. Keeps the manufacturer↔category browse view in §5 clean and gives the duplicate-detection nudge above a much more reliable signal (matching manufacturer + similar model name) than name-matching alone.
- **Decided: catalog entries reference by id, accepting the risk that a careless edit to a public entry could affect diagrams already using it** (see the revised §1 note) — worked through via concrete scenarios: this is strictly better for the common cases (your own edits propagating across your diagrams, cosmetic fixes to public entries reaching existing users), and if an edit does surface a real problem, that's arguably diagnosing something that was actually wrong rather than "breaking" a diagram. Two things make the remaining risk manageable rather than ignored:
- **Impact check before an Admin acts.** Before an edit-submission is approved (or a Super Admin edits/hides a public entry directly), they see how many diagrams reference that entry and a short sample (diagram name + owner username) — computed by a privileged, admin-only function that returns only that aggregate, *not* raw access into other users' diagram contents. Regular Admins still don't get diagram visibility (only Super Admins do, per §6) — this doesn't change that boundary, it just answers "what's the blast radius" without needing it.
- **Public catalog entries are never hard-deleted**, only hidden/unpublished from new use (the same pattern already used for hiding built-in device templates) — closes off the one scenario (an entry disappearing out from under a diagram, showing "Unknown type") that live references can't otherwise defend against.
- **Decided: site-wide announcements.** A new lightweight concept: an Admin can post a dismissible banner that every signed-in user sees (e.g. "the XLR-to-TRS adapter cable's compatibility changed on 2026-09-08, check any diagrams using it") — offered as a follow-up step right after acting on an impact-flagged change, so users aren't surprised by something that already shipped.
- **Future TODO, not yet built: a real `role` concept on `PortType`.** Surfaced while building the gendered connector catalog (2026-09-27) — `family`/`compatibleFamilyIds` alone can express "these two types may connect" but not "these two ports are the *same* declared type and must *not* self-pair" (e.g. USB-C Host must not wire to another USB-C Host). The compatibility engine's base rule is "identical family is always compatible," which every ordinary gendered pair relies on (two "XLR (Female)" ports on different devices should connect via a cable) but which makes true role exclusivity (Host-vs-Host, Device-vs-Device) currently unenforceable through catalog data alone. Fixing it needs an actual code change in `compatibility.ts` (an explicit role field + logic, not just another family flavor). Low-stakes today — USB-C Host/Device is the only pair using it — deliberately deferred rather than built speculatively.
## 4. Canvas — Compact & Expanded Device Views
**Compact view:**
- Shows device name only (no make/model) plus aggregate connected/open counts per input/output/bidirectional group, so you can tell what's still available at a glance without seeing every individual port.
- Connections to another device are drawn as a single "virtual cable" per connection-type group rather than one line per physical port, when more than one real connection of that type exists between the same two devices.
- Aggregated virtual cables need a visual marker (tag/label/style) so it's obvious at a glance that a line represents more than one real cable.
**Expanded view:**
- What the app already does today — every port shown individually with name and type, full device name/make/model, one real line per real connection.
- **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
- **Public device menu:** searchable; browsable both by category→manufacturer and manufacturer→category (two view modes, not a single fixed hierarchy).
- **User device menu:** searchable, browsed by category (the app's existing category system — see the categories feature already built — extends naturally here). Visible only to its owner (and Admins/Super Admins). Entries can be submitted for public promotion from here.
## 6. Admin / Super Admin Interface
| Capability | Admin | Super Admin |
|---|---|---|
| View/approve/reject device & port/cable-type submissions | ✓ | ✓ |
| CRUD public devices/ports/cables | ✓ | ✓ |
| CRUD *any* device/port/cable (public or user-owned) | | ✓ |
| View any user's diagrams | | ✓ |
| CRUD any user's diagrams | | ✓ |
| CRUD user accounts | | ✓ |
| Suspend/ban a user account (reversible, without deleting their data) | | ✓ |
| View/edit a user's password | never — nobody, at any role |
- **Decided: suspend/ban is a separate, lighter capability from delete.** Blocks login without destroying the account's data, so a problem user can be dealt with without an irreversible action.
## 7. Bill of Materials Enhancements
- Cables of the same type grouped together; expand with a chevron to see the individual cable-length entries within that type.
- Cables that also share the same *length* get their own subtotal count and total length.
- Cost input for each cable (by type+length) and for each device in the diagram; a grand total cost across the diagram.
- **Recommended:** store cost as an optional *suggested default* on the device/cable type definition (public or user-defined), but let each diagram override it — public catalog prices will drift out of date and vary by vendor/region, so the diagram-level number should always win. This also means public catalog entries don't need price moderation as part of the review workflow.
## 8. Diagram Management
- A user can see, load, and delete their own saved diagrams.
- **Decided:** diagrams can be shared with specific people (not public-gallery-by-default, not strictly private-only) — this adds a collaborators concept to the data model (diagram owner + a list of users with access).
- **Decided: collaborator permissions.** Configurable per-collaborator — the owner picks view-only or edit access for each person they share with individually, rather than one fixed permission level for everyone the diagram is shared with.
- **Implementation note:** this makes the diagram RLS policies meaningfully more complex than a simple "owner_id = you" check — they need to account for owner access, per-collaborator permission level, and Super Admin override all composing correctly. Worth keeping specifically in mind when the RLS test coverage from §1 gets written for diagrams.
- **Decided: co-editing model.** Turn-based shared access, not real-time simultaneous co-editing. Collaborators (with edit access) can make changes, but there's no live-cursor/multiplayer layer — no realtime conflict-resolution engine needed for this.
- **Decided: naming.** Rename "Project" to "Diagram" throughout the app (code, UI, docs) for consistency with the notes and this plan. This includes renaming the `ProjectRepository` interface and its implementations (see §1) as part of that pass.
- **Future idea, not in scope now:** a "Project" could later become its own concept above Diagram — a folder-like container holding multiple related Diagrams (e.g. all the diagrams for one physical rig or one client). Worth keeping the data model open to this later without building it now.
- **Decided: keep a rolling window of recent diagram snapshots** (e.g. last N saves, or last 30 days — exact policy TBD when this is built) as an undo/recovery safety net. Not full version control, but now that turn-based collaborators can edit a diagram, a bad edit — yours or theirs — needs to be recoverable.
- **Decided: migration path for existing local data is the app's existing JSON export/import**, not a dedicated importer. Export the current local diagram, sign up for a real account, import it in — no new code required.
---
## 9. Suggested Phased Rollout
The full scope above is a lot to build at once. A rough sequence that keeps each phase shippable and testable on its own:
1. **Local backend foundation** — stand up self-hosted Supabase locally with git-tracked migrations from day one, migrate from localStorage-only to a real `DiagramRepository` implementation backed by it (renamed from today's `ProjectRepository`, per the naming decision in §8), single-user only (no roles/sharing/public library yet). Diagrams already snapshot catalog data rather than live-referencing it, per §1. This alone turns the app from a browser-toy into something with durable, real storage.
2. **Auth** — Google SSO + manual signup/signin via Supabase Auth, tied to the diagrams from phase 1 (one user, own diagrams). Includes account auto-linking on matching email, the hard email-verification gate, and baseline abuse-prevention/rate-limiting from §2.
3. **Multi-diagram management** — the diagram list/load/delete UI, since today's app only ever has one active diagram (currently called "Project" in code, pending the rename). Existing JSON export/import doubles as the migration path for current local data, per §8.
4. **Public vs. private device/port/cable catalogs + submission workflow** — the biggest chunk of new complexity; probably worth its own sub-phasing (public *read* catalog first, then user-submission, then the Admin review queue with its diff view, in-app notifications, duplicate-detection nudge, in-place promotion on approval, resubmittable rejections, and per-user submission cap, all per §3). Includes the normalized manufacturer catalog alongside categories.
5. **Roles & Admin/Super Admin interface** — layer RBAC in once there's something (submissions, other users' data) that actually needs gating. Includes account suspend/ban (§6). RLS policy test coverage (§1) belongs here too, alongside the policies themselves — especially for diagrams, once collaborator permissions make those policies non-trivial (§8).
6. **Diagram sharing/collaborators** — turn-based access with per-collaborator view/edit permissions, plus the rolling-snapshot undo/recovery safety net, per §8 — the two are related (recoverability matters more once someone besides the owner can edit).
7. **BOM cost tracking** — largely independent of the backend work; could realistically be pulled earlier if you want a quick visible win.
8. **Compact/expanded canvas views + aggregated cables** — also largely independent/frontend-only; another candidate to pull earlier if desired.
This is a suggested order, not a commitment — happy to reshuffle if priorities change.
---
## 10. Status
Everything raised across all three planning passes has been answered and folded into the relevant sections above as **Decided** — nothing is currently blocked on a decision. Two things intentionally left loose rather than answered, both low-stakes and easy to settle when actually built: the future "Project groups multiple Diagrams" idea noted in §8, and the exact grouping rule for aggregated virtual cables in compact view (§4).
A fourth pass (while implementing §3) revised the §1 snapshot-vs-reference decision — catalog entries are referenced live by id, not embedded as full copies — and added the impact-check-before-editing, never-hard-delete-public-entries, and site-announcements decisions to §3 as the mitigations that make that safe in practice.
+1871 -1
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -5,11 +5,15 @@
"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",
"export-seed": "node scripts/export-seed.mjs",
"db:reset": "node scripts/export-seed.mjs --check && npx supabase db reset"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.115.0",
"@xyflow/react": "^12.11.5", "@xyflow/react": "^12.11.5",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
@@ -24,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"
} }
} }
+166
View File
@@ -0,0 +1,166 @@
// Generates supabase/seed.sql from the local Supabase database's own public
// catalog rows (is_public = true), rather than from a hand-written source
// file — supersedes the old generate-seed.mjs/src/domain/library.ts pair, so
// the app's own device/category/port/cable-type editors (used as an Admin,
// with "Publish directly to the shared library" checked) become the one way
// new seed data gets authored. Run via: npm run export-seed
//
// `--check` instead diffs what this would write against the current
// supabase/seed.sql and exits 1 without writing if they differ — used by
// `npm run db:reset` to refuse to blow away catalog entries you haven't
// exported yet (see package.json).
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
const DB_CONTAINER = 'supabase_db_av-planner'
const SEED_PATH = new URL('../supabase/seed.sql', import.meta.url)
function queryJson(sql) {
const wrapped = `select coalesce(json_agg(t), '[]'::json) from (${sql}) t;`
const output = execFileSync(
'docker',
['exec', DB_CONTAINER, 'psql', '-U', 'postgres', '-d', 'postgres', '-t', '-A', '-c', wrapped],
{ encoding: 'utf8' },
)
return JSON.parse(output.trim())
}
function sqlString(value) {
if (value === undefined || value === null) return 'null'
return `'${String(value).replace(/'/g, "''")}'`
}
function sqlTextArray(values) {
if (!values || values.length === 0) return "'{}'"
return `'{${values.map((v) => `"${v.replace(/"/g, '\\"')}"`).join(',')}}'`
}
function sqlNumber(value) {
return value === undefined || value === null ? 'null' : String(value)
}
function loadCatalog() {
return {
categories: queryJson('select id, name from device_categories where is_public order by name'),
manufacturers: queryJson('select id, name from manufacturers where is_public order by name'),
portTypes: queryJson(
'select id, name, category, family, compatible_family_ids, max_connections from port_types where is_public order by name',
),
cableTypes: queryJson(
'select id, name, family, family2, unit, cost_per_unit from cable_types where is_public order by name',
),
deviceTemplates: queryJson(
'select id, name, category_id, manufacturer_id, model, cost from device_templates where is_public order by name',
),
devicePorts: queryJson(
`select id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable
from device_template_ports
where device_template_id in (select id from device_templates where is_public)
order by device_template_id, sort_order`,
),
}
}
function buildSeedContent(catalog) {
const { categories, manufacturers, portTypes, cableTypes, deviceTemplates, devicePorts } = catalog
const lines = []
lines.push("-- Generated by scripts/export-seed.mjs from the local Supabase database's public")
lines.push('-- catalog rows. Do not hand-edit — author new devices/connectors via the app\'s')
lines.push('-- GUI instead (as an Admin, check "Publish directly to the shared library"),')
lines.push('-- then re-run: npm run export-seed')
lines.push('-- Seeds the public catalog: is_public = true, owner_id = null (system-owned).')
lines.push('')
lines.push('-- Device categories')
for (const c of categories) {
lines.push(`insert into public.device_categories (id, name, is_public) values (${sqlString(c.id)}, ${sqlString(c.name)}, true);`)
}
lines.push('')
lines.push('-- Manufacturers')
for (const m of manufacturers) {
lines.push(`insert into public.manufacturers (id, name, is_public) values (${sqlString(m.id)}, ${sqlString(m.name)}, true);`)
}
lines.push('')
lines.push('-- Port types')
for (const pt of portTypes) {
lines.push(
`insert into public.port_types (id, name, category, family, compatible_family_ids, max_connections, is_public) values (${sqlString(pt.id)}, ${sqlString(pt.name)}, ${sqlString(pt.category)}, ${sqlString(pt.family)}, ${sqlTextArray(pt.compatible_family_ids)}, ${sqlNumber(pt.max_connections)}, true);`,
)
}
lines.push('')
lines.push('-- Cable types')
for (const ct of cableTypes) {
lines.push(
`insert into public.cable_types (id, name, family, family2, unit, cost_per_unit, is_public) values (${sqlString(ct.id)}, ${sqlString(ct.name)}, ${sqlString(ct.family)}, ${sqlString(ct.family2)}, ${sqlString(ct.unit)}, ${sqlNumber(ct.cost_per_unit)}, true);`,
)
}
lines.push('')
lines.push('-- Device templates + their ports')
const portsByTemplate = new Map()
for (const port of devicePorts) {
const list = portsByTemplate.get(port.device_template_id) ?? []
list.push(port)
portsByTemplate.set(port.device_template_id, list)
}
for (const dt of deviceTemplates) {
lines.push(
`insert into public.device_templates (id, name, category_id, manufacturer_id, model, cost, is_public) values (${sqlString(dt.id)}, ${sqlString(dt.name)}, ${sqlString(dt.category_id)}, ${sqlString(dt.manufacturer_id)}, ${sqlString(dt.model)}, ${sqlNumber(dt.cost)}, true);`,
)
for (const port of portsByTemplate.get(dt.id) ?? []) {
lines.push(
`insert into public.device_template_ports (id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable) values (${sqlString(port.id)}, ${sqlString(port.device_template_id)}, ${sqlString(port.name)}, ${sqlString(port.direction)}, ${sqlString(port.port_type_id)}, ${port.sort_order}, ${port.built_in_cable});`,
)
}
}
lines.push('')
return lines.join('\n') + '\n'
}
function summarize(catalog) {
return `${catalog.categories.length} categories, ${catalog.manufacturers.length} manufacturers, ${catalog.portTypes.length} port types, ${catalog.cableTypes.length} cable types, ${catalog.deviceTemplates.length} device templates`
}
const isCheck = process.argv.includes('--check')
let catalog
try {
catalog = loadCatalog()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (isCheck) {
// Best-effort safety net, not a hard gate — if the local DB isn't
// reachable (Docker down, container not started yet) don't block a
// reset over it.
console.warn(`Couldn't reach the local Supabase database to check seed.sql — proceeding without checking.\n${message}`)
process.exit(0)
}
console.error(`Failed to read the public catalog from the local Supabase database.\n${message}`)
process.exit(1)
}
const content = buildSeedContent(catalog)
if (isCheck) {
const current = existsSync(SEED_PATH) ? readFileSync(SEED_PATH, 'utf8') : ''
if (current === content) {
console.log('supabase/seed.sql matches the database — safe to reset.')
process.exit(0)
}
console.error(
[
"supabase/seed.sql is out of date with the local database's public catalog.",
'Resetting now would discard whatever you\'ve published since it was last exported.',
'',
'Run `npm run export-seed` first to capture it, or run `npx supabase db reset` directly if you want to discard it.',
].join('\n'),
)
process.exit(1)
}
writeFileSync(SEED_PATH, content)
console.log(`Wrote supabase/seed.sql (${summarize(catalog)})`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diagrav — Plan your wiring before you buy a single cable</title>
<meta name="description" content="Diagrav is a visual tool for planning AV and DAWless synth setups: drag devices onto a canvas, wire them up with real connector compatibility checking, and get an automatic shopping list." />
<link rel="canonical" href="https://diagrav.com/" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://diagrav.com/" />
<meta property="og:title" content="Diagrav" />
<meta property="og:description" content="Plan your AV or DAWless synth wiring visually, catch incompatible connections before you buy anything, and get an automatic shopping list." />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<nav class="nav">
<div class="wrap">
<a class="brand" href="#top"><span class="dot"></span>Diagrav</a>
<div class="nav-right">
<div class="nav-links">
<a href="#features">Features</a>
<a href="#canvas">How it works</a>
<a href="#library">Library</a>
</div>
<div class="nav-auth">
<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>
</nav>
<header class="hero" id="top">
<div class="wrap">
<span class="eyebrow">🔌 Plan the whole rig before you buy anything</span>
<h1>Wire up your gear <span class="accent">on screen</span><br>before you touch a cable</h1>
<p class="lede">
Diagrav is a visual planning tool for anyone wiring together audio/video gear or a
DAWless synth setup. Drag devices onto a canvas, connect their ports, and let the app
catch incompatible connections and total up your shopping list — before you've spent a dollar.
</p>
<div class="hero-shot">
<img src="images/canvas.jpg" alt="A wired synth rig diagram in Diagrav, showing a MIDI keyboard, drum machine, synth, mixer, and studio monitors connected with labeled cables" />
</div>
</div>
</header>
<section id="features">
<div class="wrap">
<div class="section-head">
<h2>Everything you need to plan a rig, nothing you don't</h2>
<p>Built for the moment before a purchase — when you're still figuring out what actually connects to what.</p>
</div>
<div class="features">
<div class="feature">
<div class="icon">🖱️</div>
<h3>Drag-and-drop canvas</h3>
<p>Place devices from a library of ports and connectors, then draw cables between them exactly like you would on a whiteboard — except it remembers everything.</p>
</div>
<div class="feature">
<div class="icon">✅</div>
<h3>Real compatibility checking</h3>
<p>Every connector type knows what it physically mates with. Try to wire an XLR into a 1/4" jack and the app tells you why not, instead of finding out at the store.</p>
</div>
<div class="feature">
<div class="icon">🧾</div>
<h3>Automatic shopping list</h3>
<p>Every cable and device on the canvas rolls up into a bill of materials — grouped by cable type and length, with running costs, so you know exactly what to order.</p>
</div>
<div class="feature">
<div class="icon">🎚️</div>
<h3>Multi-cable bundles</h3>
<p>A stereo pair or an 8-channel snake is really one purchase, not several. Bundle the individual runs together so the shopping list counts it the way you'd actually buy it.</p>
</div>
<div class="feature">
<div class="icon">📚</div>
<h3>A growing shared library</h3>
<p>Connector types, cable types, and device templates are shared across everyone using the app — with your own private additions for anything niche or proprietary.</p>
</div>
<div class="feature">
<div class="icon">🤝</div>
<h3>Share &amp; collaborate</h3>
<p>Share a diagram with view or edit access, keep a version history as it evolves, and pick up right where you left off.</p>
</div>
</div>
</div>
</section>
<section id="canvas">
<div class="wrap">
<div class="showcase-row">
<div class="showcase-copy">
<div class="showcase-tag">The canvas</div>
<h3>See your whole signal path at a glance</h3>
<p>
Drop a device onto the canvas, expand it to see its individual ports, and drag a
connection from one port to another. Every wire is a real object — it knows which
two ports it joins, what cable type it needs, and how long a run it is.
</p>
<ul>
<li>Devices collapse to a compact view when you don't need the detail</li>
<li>MIDI, audio, video, network, and power connectors, side by side</li>
<li>Bundle related runs (like a stereo pair) into one visually distinct cable</li>
</ul>
</div>
<div class="showcase-shot">
<img src="images/canvas.jpg" alt="Wired device diagram on the Diagrav canvas" />
</div>
</div>
<div class="showcase-row reverse">
<div class="showcase-copy">
<div class="showcase-tag">The shopping list</div>
<h3>A bill of materials that updates itself</h3>
<p>
As you wire things up, Diagrav keeps a running bill of materials in the side
panel — every cable type you'll need, grouped by length, with a running cost total.
Bundled cables count as the single item you'll actually order.
</p>
<ul>
<li>Cables grouped by type, then by length, so you know exactly what to buy and how many</li>
<li>Device and cable costs roll up into one grand total</li>
<li>Anything missing a cost is flagged, never silently treated as free</li>
</ul>
</div>
<div class="showcase-shot">
<img src="images/bom.jpg" alt="Bill of materials panel showing cables grouped by type and length with a running cost total" />
</div>
</div>
<div class="showcase-row" id="library">
<div class="showcase-copy">
<div class="showcase-tag">The library</div>
<h3>A device catalog that grows with you</h3>
<p>
Browse built-in devices and connectors by category or manufacturer, search across
everything at once, and drop in your own custom gear when something isn't in the
catalog yet — synths, mixers, adapters, whatever your rack actually has.
</p>
<ul>
<li>Organize by category or by manufacturer, whichever matches how you think</li>
<li>Add your own devices and connector types in minutes</li>
<li>Submit your additions back to the shared catalog for everyone else to use</li>
</ul>
</div>
<div class="showcase-shot">
<img src="images/palette.jpg" alt="Device library panel organized by category, showing built-in and custom devices" />
</div>
</div>
</div>
</section>
<section>
<div class="wrap">
<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 app-link" href="https://app.diagrav.com/">Sign up</a>
</div>
</div>
</section>
<footer>
<div class="wrap" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;">
<span>Diagrav</span>
<span>Built for planning AV and DAWless synth setups.</span>
</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>
+412
View File
@@ -0,0 +1,412 @@
:root {
--ink: #1e1b2e;
--ink-soft: #4b4763;
--bg: #fafafa;
--panel: #ffffff;
--border: #e7e5f0;
--indigo: #4f46e5;
--indigo-dark: #4338ca;
--indigo-soft: #eef2ff;
--amber: #d97706;
--teal: #059669;
--shadow: 0 1px 2px rgba(30, 27, 46, 0.04), 0 8px 24px rgba(30, 27, 46, 0.06);
--max: 1080px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
img {
max-width: 100%;
display: block;
}
a {
color: var(--indigo);
}
.wrap {
max-width: var(--max);
margin: 0 auto;
padding: 0 24px;
}
/* ---------- Nav ---------- */
.nav {
border-bottom: 1px solid var(--border);
background: rgba(250, 250, 250, 0.85);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.nav .wrap {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
.brand {
font-weight: 700;
font-size: 17px;
color: var(--ink);
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
}
.brand .dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--indigo);
box-shadow: 0 0 0 3px var(--indigo-soft);
}
.nav-right {
display: flex;
align-items: center;
gap: 32px;
}
.nav-links {
display: flex;
gap: 28px;
font-size: 14px;
font-weight: 500;
}
.nav-links a {
color: var(--ink-soft);
text-decoration: none;
}
.nav-links a:hover {
color: var(--ink);
}
.nav-auth {
display: flex;
align-items: center;
gap: 18px;
}
.nav-login {
font-size: 14px;
font-weight: 500;
color: var(--ink-soft);
text-decoration: none;
}
.nav-login:hover {
color: var(--ink);
}
@media (max-width: 640px) {
.nav-links {
display: none;
}
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
border: 1px solid transparent;
}
.btn-primary {
background: var(--indigo);
color: #fff;
}
.btn-primary:hover {
background: var(--indigo-dark);
}
.btn-sm {
font-size: 13px;
padding: 7px 16px;
}
.btn-lg {
font-size: 15px;
padding: 13px 30px;
}
/* ---------- Hero ---------- */
.hero {
padding: 88px 0 64px;
text-align: center;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--indigo-dark);
background: var(--indigo-soft);
border-radius: 999px;
padding: 6px 14px;
margin-bottom: 24px;
}
h1 {
font-size: clamp(32px, 5vw, 52px);
line-height: 1.12;
letter-spacing: -0.02em;
margin: 0 0 20px;
}
h1 .accent {
color: var(--indigo);
}
.lede {
font-size: clamp(16px, 2vw, 19px);
color: var(--ink-soft);
max-width: 640px;
margin: 0 auto 36px;
}
.hero-shot {
margin-top: 8px;
border-radius: 16px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
overflow: hidden;
max-width: 960px;
margin-left: auto;
margin-right: auto;
}
.hero-shot img {
width: 100%;
}
/* ---------- Section scaffolding ---------- */
section {
padding: 72px 0;
}
.section-head {
max-width: 620px;
margin: 0 auto 48px;
text-align: center;
}
.section-head h2 {
font-size: clamp(24px, 3.2vw, 32px);
letter-spacing: -0.01em;
margin: 0 0 12px;
}
.section-head p {
color: var(--ink-soft);
margin: 0;
font-size: 16px;
}
/* ---------- Feature grid ---------- */
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 860px) {
.features {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.features {
grid-template-columns: 1fr;
}
}
.feature {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px;
}
.feature .icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-bottom: 16px;
background: var(--indigo-soft);
}
.feature h3 {
font-size: 16px;
margin: 0 0 8px;
}
.feature p {
font-size: 14px;
color: var(--ink-soft);
margin: 0;
}
/* ---------- Showcase (alternating screenshot rows) ---------- */
.showcase-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
align-items: center;
padding: 48px 0;
border-top: 1px solid var(--border);
}
.showcase-row:first-of-type {
border-top: none;
}
.showcase-row.reverse .showcase-copy {
order: 2;
}
.showcase-row.reverse .showcase-shot {
order: 1;
}
@media (max-width: 800px) {
.showcase-row,
.showcase-row.reverse .showcase-copy,
.showcase-row.reverse .showcase-shot {
order: initial;
}
.showcase-row {
grid-template-columns: 1fr;
gap: 24px;
}
}
.showcase-tag {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--indigo);
margin-bottom: 10px;
}
.showcase-copy h3 {
font-size: 22px;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
.showcase-copy p {
color: var(--ink-soft);
font-size: 15px;
margin: 0 0 12px;
}
.showcase-copy ul {
margin: 16px 0 0;
padding: 0;
list-style: none;
font-size: 14px;
color: var(--ink-soft);
}
.showcase-copy li {
padding-left: 22px;
position: relative;
margin-bottom: 8px;
}
.showcase-copy li::before {
content: "✓";
position: absolute;
left: 0;
color: var(--teal);
font-weight: 700;
}
.showcase-shot {
border-radius: 14px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
overflow: hidden;
}
/* ---------- Callout strip ---------- */
.strip {
background: var(--ink);
color: #fff;
border-radius: 20px;
padding: 48px;
text-align: center;
}
.strip h2 {
margin: 0 0 12px;
font-size: clamp(22px, 3vw, 28px);
}
.strip p {
margin: 0 auto;
color: #c7c4d9;
max-width: 520px;
font-size: 15px;
}
.strip-cta {
margin-top: 28px;
}
/* ---------- Footer ---------- */
footer {
border-top: 1px solid var(--border);
padding: 32px 0;
font-size: 13px;
color: var(--ink-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
+46
View File
@@ -1,5 +1,51 @@
import type { Session } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
import CompleteProfileScreen from './components/auth/CompleteProfileScreen'
import LoginScreen from './components/auth/LoginScreen'
import AppShell from './components/layout/AppShell' import AppShell from './components/layout/AppShell'
import { supabase } from './data/supabaseClient'
export default function App() { export default function App() {
// undefined = still checking for an existing session; null = signed out.
const [session, setSession] = useState<Session | null | undefined>(undefined)
// undefined = haven't checked yet; null = has one; true = needs to set one.
const [needsUsername, setNeedsUsername] = useState<boolean | null | undefined>(undefined)
useEffect(() => {
supabase.auth.getSession().then(({ data }) => setSession(data.session))
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => setSession(session))
return () => subscription.unsubscribe()
}, [])
useEffect(() => {
// Nothing to check while signed out — the render logic below never
// reads needsUsername in that case anyway.
if (!session) return
supabase
.from('profiles')
.select('username')
.eq('id', session.user.id)
.single()
.then(({ data }) => setNeedsUsername(!data?.username))
}, [session])
if (session === undefined) {
return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (!session) {
return <LoginScreen />
}
if (needsUsername === undefined) {
return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (needsUsername) {
return <CompleteProfileScreen onDone={() => setNeedsUsername(false)} />
}
return <AppShell /> return <AppShell />
} }
+352
View File
@@ -0,0 +1,352 @@
import type { User, UserIdentity } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
import { supabase } from '../../data/supabaseClient'
import { useAuthStore } from '../../state/authStore'
import Modal from '../common/Modal'
/** Username changes don't need the current-password check below — unlike
* email/password, a wrong username doesn't lock anyone out of anything. */
function UsernameSection({ currentUsername, userId }: { currentUsername: string; userId: string }) {
const loadAuth = useAuthStore((s) => s.load)
const [username, setUsername] = useState(currentUsername)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const trimmed = username.trim()
const canSave = trimmed.length > 0 && trimmed !== currentUsername
const handleSave = async () => {
if (!canSave) return
setError(null)
setSaved(false)
setLoading(true)
try {
const { error } = await supabase.from('profiles').update({ username: trimmed }).eq('id', userId)
if (error) throw error.code === '23505' ? new Error('That username is already taken.') : error
await loadAuth()
setSaved(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
return (
<section className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Username</h3>
<div className="flex gap-1.5">
<input
value={username}
onChange={(e) => {
setUsername(e.target.value)
setSaved(false)
}}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={handleSave}
disabled={loading || !canSave}
className="shrink-0 rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{loading ? 'Saving…' : 'Save'}
</button>
</div>
{error && <p className="text-[11px] text-red-600">{error}</p>}
{saved && !error && <p className="text-[11px] text-emerald-600">Username updated.</p>}
</section>
)
}
/** Email and password changes both require the current password up front
* (Supabase validates it server-side via `current_password`) — extra
* defense-in-depth beyond Supabase's own double-confirmation email flow,
* so a hijacked-but-unlocked session can't quietly lock the real owner
* out. Only rendered when the account actually has a password identity —
* see ProfileModal's hasPasswordIdentity check. */
function EmailSection({ currentEmail }: { currentEmail: string }) {
const [newEmail, setNewEmail] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const canSave = newEmail.trim().length > 0 && newEmail.trim() !== currentEmail && currentPassword.length > 0
const handleSave = async () => {
if (!canSave) return
setError(null)
setPending(false)
setLoading(true)
try {
const { error } = await supabase.auth.updateUser({
email: newEmail.trim(),
current_password: currentPassword,
})
if (error) throw error
setNewEmail('')
setCurrentPassword('')
setPending(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
return (
<section className="space-y-1.5 border-t border-slate-100 pt-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Email</h3>
<p className="text-[11px] text-slate-400">Current: {currentEmail}</p>
<input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value)
setPending(false)
}}
placeholder="New email"
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Current password"
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={handleSave}
disabled={loading || !canSave}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{loading ? 'Saving…' : 'Change email'}
</button>
{error && <p className="text-[11px] text-red-600">{error}</p>}
{pending && !error && (
<p className="text-[11px] text-emerald-600">
Check both your old and new inbox — the change won't take effect until you confirm both.
</p>
)}
</section>
)
}
function PasswordSection() {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const canSave = currentPassword.length > 0 && newPassword.length >= 6
const handleSave = async () => {
if (!canSave) return
setError(null)
setSaved(false)
setLoading(true)
try {
const { error } = await supabase.auth.updateUser({ password: newPassword, current_password: currentPassword })
if (error) throw error
setCurrentPassword('')
setNewPassword('')
setSaved(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
return (
<section className="space-y-1.5 border-t border-slate-100 pt-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Password</h3>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Current password"
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
<input
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value)
setSaved(false)
}}
placeholder="New password (min. 6 characters)"
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={handleSave}
disabled={loading || !canSave}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{loading ? 'Saving…' : 'Change password'}
</button>
{error && <p className="text-[11px] text-red-600">{error}</p>}
{saved && !error && <p className="text-[11px] text-emerald-600">Password updated.</p>}
</section>
)
}
/**
* organized-ideas.md §2's self-service Google-account migration: link a new
* Google identity while still signed in with the old one, confirm it
* worked, then unlink the old one — same account/UUID throughout, so every
* owned diagram and private catalog entry carries over with zero data
* migration. (The other tier from that plan — a Super Admin tool for
* someone who's already lost access to their old Google account — is a
* separate, bigger feature: reassigning/merging accounts you don't own,
* not something this self-service section can help with.)
*
* Requires `enable_manual_linking = true` in supabase/config.toml — off by
* default since Supabase treats letting a user attach an arbitrary second
* identity as a deliberate opt-in, not something to enable silently.
*/
function GoogleIdentitiesSection() {
const [identities, setIdentities] = useState<UserIdentity[] | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadIdentities = async () => {
const { data, error } = await supabase.auth.getUserIdentities()
if (error) {
setError(error.message)
return
}
setIdentities(data.identities)
}
useEffect(() => {
supabase.auth.getUserIdentities().then(({ data, error }) => {
if (error) setError(error.message)
else setIdentities(data.identities)
})
}, [])
const googleIdentities = identities?.filter((i) => i.provider === 'google') ?? []
// unlinkIdentity refuses to leave an account with zero identities — this
// mirrors that so the button reads as disabled rather than failing.
const canUnlink = (identities?.length ?? 0) > 1
const handleLink = async () => {
setError(null)
setLoading(true)
// Redirects the whole page to Google's consent screen on success — if
// we're still here, it failed before ever leaving.
const { error } = await supabase.auth.linkIdentity({
provider: 'google',
options: { redirectTo: window.location.origin },
})
if (error) {
setError(error.message)
setLoading(false)
}
}
const handleUnlink = async (identity: UserIdentity) => {
setError(null)
setLoading(true)
try {
const { error } = await supabase.auth.unlinkIdentity(identity)
if (error) throw error
await loadIdentities()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
return (
<section className="space-y-1.5 border-t border-slate-100 pt-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Google accounts</h3>
<p className="text-[11px] text-slate-400">
Moving to a new Google account? Link it here, sign out and confirm you can sign back in with it, then unlink
the old one — this stays the same account throughout, so nothing you own needs to move.
</p>
{googleIdentities.length === 0 ? (
<p className="text-[11px] italic text-slate-400">No Google account linked.</p>
) : (
<ul className="space-y-1">
{googleIdentities.map((identity) => (
<li
key={identity.identity_id}
className="flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1 text-xs"
>
<span className="truncate text-slate-700">
{(identity.identity_data as { email?: string } | undefined)?.email ?? identity.id}
</span>
<button
onClick={() => handleUnlink(identity)}
disabled={loading || !canUnlink}
title={!canUnlink ? "Can't unlink your only sign-in method" : 'Unlink this Google account'}
className="shrink-0 rounded bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
>
Unlink
</button>
</li>
))}
</ul>
)}
<button
onClick={handleLink}
disabled={loading}
className="rounded bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40"
>
+ Link a Google account
</button>
{error && <p className="text-[11px] text-red-600">{error}</p>}
</section>
)
}
/** Reachable from TopBar's Account menu — a home for anything about your
* own account, not any one diagram. Username always shown; email/password
* only for an account that actually has a password identity (a Google-
* only sign-in has neither — their email is Google's, and there's no
* password to change or use as the current-password check below). */
export default function ProfileModal({ onClose }: { onClose: () => void }) {
const username = useAuthStore((s) => s.username)
const userId = useAuthStore((s) => s.userId)
const [user, setUser] = useState<User | null | undefined>(undefined)
useEffect(() => {
supabase.auth.getUser().then(({ data }) => setUser(data.user))
}, [])
const hasPasswordIdentity = !!user?.identities?.some((i) => i.provider === 'email')
return (
<Modal title="Profile" onClose={onClose} width="max-w-md">
<div className="space-y-3">
{username && userId && <UsernameSection currentUsername={username} userId={userId} />}
{user === undefined ? (
<p className="border-t border-slate-100 pt-3 text-xs italic text-slate-400">Loading…</p>
) : hasPasswordIdentity ? (
<>
<EmailSection currentEmail={user?.email ?? ''} />
<PasswordSection />
</>
) : (
<p className="border-t border-slate-100 pt-3 text-[11px] text-slate-500">
You sign in with Google — your email and password are managed by your Google account.
</p>
)}
<GoogleIdentitiesSection />
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,160 @@
import { useState } from 'react'
import type { AccountMigrationImpact, AdminUserSummary } from '../../data/AdminUserRepository'
import { useAdminUserStore } from '../../state/adminUserStore'
import Modal from '../common/Modal'
/**
* organized-ideas.md §2's Super-Admin tier for someone who's lost access to
* their old account entirely (the self-service link/unlink path in
* ProfileModal only covers "I still have access to the old account too").
* Deliberately narrow: moves ownership of diagrams/private catalog
* entries/submissions, never touches credentials, never deletes the old
* account — see migrate_account_ownership()'s own migration comment for
* the full reasoning.
*/
export default function AccountMigrationModal({
fromUser,
users,
onClose,
}: {
fromUser: AdminUserSummary
users: AdminUserSummary[]
onClose: () => void
}) {
const previewMigration = useAdminUserStore((s) => s.previewMigration)
const migrateAccount = useAdminUserStore((s) => s.migrateAccount)
// Already-migrated accounts are empty husks — not sensible migration
// targets themselves.
const candidates = users.filter((u) => u.id !== fromUser.id && !u.migratedToUserId)
const [toUserId, setToUserId] = useState(candidates[0]?.id ?? '')
const [notes, setNotes] = useState('')
const [preview, setPreview] = useState<AccountMigrationImpact | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const toUser = users.find((u) => u.id === toUserId)
const handlePreview = async () => {
if (!toUserId) return
setError(null)
setPreviewLoading(true)
try {
setPreview(await previewMigration(fromUser.id, toUserId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setPreviewLoading(false)
}
}
const handleMigrate = async () => {
if (!toUserId || !notes.trim() || !preview) return
setError(null)
setBusy(true)
try {
await migrateAccount(fromUser.id, toUserId, notes.trim())
setDone(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setBusy(false)
}
}
return (
<Modal title="Migrate Account" onClose={onClose} width="max-w-lg">
{done ? (
<div className="space-y-3 text-xs">
<p className="rounded bg-emerald-50 px-3 py-2 text-emerald-700">
Moved "{fromUser.username}"'s data to "{toUser?.username}". {fromUser.username}'s account still exists,
just empty now — nothing was deleted.
</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Close
</button>
</div>
</div>
) : candidates.length === 0 ? (
<p className="text-xs italic text-slate-400">No other account to migrate this into.</p>
) : (
<div className="space-y-3 text-xs">
<p className="text-slate-500">
Moves every diagram, private catalog entry, and submission owned by{' '}
<span className="font-medium text-slate-700">{fromUser.username}</span> to another account. Doesn't touch{' '}
{fromUser.username}'s login (email, password, Google identity) and doesn't delete the account — only use
this once you've verified, independently of anything in this app, that the person asking really is{' '}
{fromUser.username}.
</p>
<label className="block text-xs font-medium text-slate-600">
Migrate to
<select
value={toUserId}
onChange={(e) => {
setToUserId(e.target.value)
setPreview(null)
}}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
{candidates.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.email})
</option>
))}
</select>
</label>
<label className="block text-xs font-medium text-slate-600">
How was this verified?
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="e.g. Confirmed over a video call; they described diagrams only the real owner would know about."
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<div className="flex items-center gap-2">
<button
onClick={handlePreview}
disabled={!toUserId || previewLoading}
className="rounded bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40"
>
{previewLoading ? 'Checking…' : 'Preview what will move'}
</button>
{preview && (
<span className="text-[11px] text-slate-500">
{preview.diagramCount} diagram{preview.diagramCount === 1 ? '' : 's'}, {preview.privateEntityCount}{' '}
private catalog {preview.privateEntityCount === 1 ? 'entry' : 'entries'}, {preview.submissionCount}{' '}
submission{preview.submissionCount === 1 ? '' : 's'}
</span>
)}
</div>
{error && <p className="rounded bg-red-50 px-2 py-1.5 text-red-700">{error}</p>}
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Cancel
</button>
<button
onClick={handleMigrate}
disabled={busy || !toUserId || !notes.trim() || !preview}
title={!preview ? 'Preview what will move first' : undefined}
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? 'Migrating…' : 'Migrate account'}
</button>
</div>
</div>
)}
</Modal>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react'
import type { AdminUserSummary, UserRole } from '../../data/AdminUserRepository'
import { useAdminUserStore } from '../../state/adminUserStore'
import { useAuthStore } from '../../state/authStore'
import Chevron from '../common/Chevron'
import Modal from '../common/Modal'
import AccountMigrationModal from './AccountMigrationModal'
const ROLE_OPTIONS: UserRole[] = ['regular', 'admin', 'super_admin']
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' })
}
function isBanned(user: AdminUserSummary): boolean {
return !!user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now()
}
export default function AdminUsersModal({ onClose }: { onClose: () => void }) {
const currentUserId = useAuthStore((s) => s.userId)
const users = useAdminUserStore((s) => s.users)
const loadUsers = useAdminUserStore((s) => s.loadUsers)
const updateRole = useAdminUserStore((s) => s.updateRole)
const updateCanPostAnnouncements = useAdminUserStore((s) => s.updateCanPostAnnouncements)
const banUser = useAdminUserStore((s) => s.banUser)
const unbanUser = useAdminUserStore((s) => s.unbanUser)
const deleteUser = useAdminUserStore((s) => s.deleteUser)
const migrations = useAdminUserStore((s) => s.migrations)
const loadMigrations = useAdminUserStore((s) => s.loadMigrations)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [migrationTarget, setMigrationTarget] = useState<AdminUserSummary | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
useEffect(() => {
loadUsers()
loadMigrations()
}, [loadUsers, loadMigrations])
const runAction = async (userId: string, action: () => Promise<void>) => {
setError(null)
setBusyId(userId)
try {
await action()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setBusyId(null)
}
}
const handleRoleChange = (user: AdminUserSummary, role: UserRole) => {
if (user.id === currentUserId && role !== 'super_admin') {
const confirmed = window.confirm(
`Change your own role to "${role}"? You'll lose Super Admin access immediately — another Super Admin (or direct DB access) would be needed to undo this.`,
)
if (!confirmed) return
}
runAction(user.id, () => updateRole(user.id, role))
}
const handleBanToggle = (user: AdminUserSummary) => {
const banned = isBanned(user)
const confirmed = window.confirm(
banned ? `Unban "${user.username}"? They'll be able to sign in again.` : `Ban "${user.username}"? This blocks sign-in but keeps their data intact — reversible.`,
)
if (!confirmed) return
runAction(user.id, () => (banned ? unbanUser(user.id) : banUser(user.id)))
}
const handleDelete = (user: AdminUserSummary) => {
const confirmed = window.confirm(
`Permanently delete "${user.username}"? This deletes their account AND all their diagrams and private catalog entries. This cannot be undone.`,
)
if (!confirmed) return
runAction(user.id, () => deleteUser(user.id))
}
return (
<Modal title="Manage Users" onClose={onClose} width="max-w-2xl">
{error && <p className="mb-2 rounded bg-red-50 px-2 py-1.5 text-xs text-red-700">{error}</p>}
<div className="space-y-1.5">
{users.map((user) => {
const busy = busyId === user.id
const self = user.id === currentUserId
const banned = isBanned(user)
return (
<div key={user.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2.5 py-1.5 text-xs">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-700">{user.username}</span>
{self && (
<span className="rounded bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-600">You</span>
)}
{banned && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">Banned</span>
)}
{user.migratedToUsername && (
<span
title="This account's data has been moved elsewhere — see migration history below"
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
>
Migrated → {user.migratedToUsername}
</span>
)}
</div>
<div className="truncate text-[10px] text-slate-400">
{user.email} · joined {formatDate(user.createdAt)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<select
value={user.role}
disabled={busy}
onChange={(e) => handleRoleChange(user, e.target.value as UserRole)}
className="rounded border border-slate-300 px-1 py-1 text-[11px] disabled:opacity-50"
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
{user.role === 'admin' && (
<label
title="Lets this specific Admin post/retire site-wide announcements"
className="flex items-center gap-1 text-[11px] text-slate-500"
>
<input
type="checkbox"
checked={user.canPostAnnouncements}
disabled={busy}
onChange={(e) => runAction(user.id, () => updateCanPostAnnouncements(user.id, e.target.checked))}
/>
Announcements
</label>
)}
<button
onClick={() => setMigrationTarget(user)}
disabled={busy || self || !!user.migratedToUsername}
title={
self
? "You can't migrate your own account"
: user.migratedToUsername
? 'Already migrated'
: 'Move this account\'s diagrams and private catalog entries to another account'
}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
>
Migrate
</button>
<button
onClick={() => handleBanToggle(user)}
disabled={busy || self}
title={self ? "You can't ban your own account" : undefined}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{banned ? 'Unban' : 'Ban'}
</button>
<button
onClick={() => handleDelete(user)}
disabled={busy || self}
title={self ? "You can't delete your own account" : undefined}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
>
Delete
</button>
</div>
</div>
)
})}
{users.length === 0 && <p className="py-2 text-center text-xs italic text-slate-400">No users found.</p>}
</div>
<div className="mt-3 border-t border-slate-100 pt-2">
<button
onClick={() => setHistoryOpen((v) => !v)}
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-[11px] text-slate-500 hover:bg-slate-100"
>
<span>Migration history ({migrations.length})</span>
<Chevron expanded={historyOpen} className="text-slate-500" />
</button>
{historyOpen && (
<ul className="mt-1 max-h-48 space-y-1 overflow-y-auto">
{migrations.length === 0 && <p className="px-1.5 py-1 text-[11px] italic text-slate-400">No migrations yet.</p>}
{migrations.map((m) => (
<li key={m.id} className="rounded border border-slate-200 bg-white px-2 py-1.5 text-[11px]">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-slate-700">
{m.fromUsername} → {m.toUsername}
</span>
<span className="shrink-0 text-slate-400">{formatDate(m.createdAt)}</span>
</div>
<div className="text-slate-500">
{m.diagramCount} diagram{m.diagramCount === 1 ? '' : 's'}, {m.privateEntityCount} private catalog{' '}
{m.privateEntityCount === 1 ? 'entry' : 'entries'}, {m.submissionCount} submission
{m.submissionCount === 1 ? '' : 's'}
</div>
<div className="mt-0.5 italic text-slate-400">"{m.verificationNotes}"</div>
</li>
))}
</ul>
)}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
{migrationTarget && (
<AccountMigrationModal fromUser={migrationTarget} users={users} onClose={() => setMigrationTarget(null)} />
)}
</Modal>
)
}
@@ -0,0 +1,24 @@
import { useAnnouncementStore } from '../../state/announcementStore'
/** Site-wide dismissible banner (organized-ideas.md §3) — rendered above
* everything else in AppShell so it's visible regardless of what's
* selected below, for every signed-in user until they dismiss it. */
export default function AnnouncementBanner() {
const current = useAnnouncementStore((s) => s.current)
const dismiss = useAnnouncementStore((s) => s.dismiss)
if (!current || current.dismissed) return null
return (
<div className="flex items-center justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900">
<p className="min-w-0">{current.message}</p>
<button
onClick={dismiss}
title="Dismiss"
className="shrink-0 rounded p-1 text-amber-500 hover:bg-amber-100 hover:text-amber-700"
>
✕
</button>
</div>
)
}
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { useAnnouncementStore } from '../../state/announcementStore'
import Modal from '../common/Modal'
/** Reachable only by whoever can_manage_announcements() allows (every Super
* Admin, or an Admin individually flagged — see AdminUsersModal). Shows
* what's currently live regardless of whether *this* viewer has dismissed
* it themselves — that flag is personal, not a proxy for "is it still
* showing to everyone else." */
export default function AnnouncementComposerModal({ onClose }: { onClose: () => void }) {
const current = useAnnouncementStore((s) => s.current)
const post = useAnnouncementStore((s) => s.post)
const retireCurrent = useAnnouncementStore((s) => s.retireCurrent)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
const runAction = async (action: () => Promise<void>) => {
setBusy(true)
try {
await action()
} finally {
setBusy(false)
}
}
const handlePost = () => {
const trimmed = message.trim()
if (!trimmed) return
runAction(async () => {
await post(trimmed)
setMessage('')
})
}
return (
<Modal title="Site Announcement" onClose={onClose} width="max-w-lg">
<div className="space-y-3">
{current ? (
<div className="rounded border border-amber-200 bg-amber-50 p-2.5 text-sm text-amber-900">
<p className="mb-2">{current.message}</p>
<button
onClick={() => runAction(retireCurrent)}
disabled={busy}
className="rounded bg-white px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
>
Retire now
</button>
</div>
) : (
<p className="text-xs italic text-slate-400">Nothing is currently showing.</p>
)}
<label className="block text-xs font-medium text-slate-600">
{current ? 'Post a new announcement (replaces the one above)' : 'Post an announcement'}
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
placeholder="e.g. The XLR-to-TRS adapter cable's compatibility changed on 2026-09-16 — check any diagrams using it."
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
<button
onClick={handlePost}
disabled={busy || !message.trim()}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Post
</button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { supabase } from '../../data/supabaseClient'
/**
* Shown when a signed-in user's profile has no username yet — the only way
* that happens today is a first-time Google sign-in, since Google doesn't
* let us collect one before the OAuth redirect the way the email/password
* form does up front. One-time gate, same hard-gate spirit as email
* verification: nothing else in the app is usable until this is set.
*/
export default function CompleteProfileScreen({ onDone }: { onDone: () => void }) {
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
const { error } = await supabase.from('profiles').update({ username: username.trim() }).eq('id', user.id)
if (error) {
// PostgrestError is a plain object, not an Error instance — wrap it
// so its message survives the catch block below instead of falling
// through to the generic fallback message.
throw error.code === '23505' ? new Error('That username is already taken.') : new Error(error.message)
}
onDone()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
return (
<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>
<label className="mt-4 block text-xs font-medium text-slate-600">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !username.trim()}
className="mt-4 w-full rounded bg-indigo-600 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Saving…' : 'Continue'}
</button>
</form>
</div>
)
}
+178
View File
@@ -0,0 +1,178 @@
import { useState } from 'react'
import { supabase } from '../../data/supabaseClient'
/**
* Auth gate: Google SSO (per organized-ideas.md §2 — no username prompt
* here, since Google doesn't let us collect one before the redirect; a
* first-time Google sign-in lands with no username and App.tsx routes them
* to CompleteProfileScreen to set one) or manual email/password signup
* (username collected up front here instead, email pulled from the form).
* Manual signup respects the hard email-verification gate from
* supabase/config.toml — sign-in is blocked until confirmed.
*/
export default function LoginScreen() {
const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmSent, setConfirmSent] = useState(false)
const handleGoogleSignIn = async () => {
setError(null)
setLoading(true)
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: window.location.origin },
})
// On success this redirects the whole page to Google, so we only ever
// reach here if kicking off the redirect itself failed.
if (error) {
setError(error.message)
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
if (mode === 'sign-in') {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
} else {
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { username: username.trim() } },
})
if (error) throw error
setConfirmSent(true)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
if (confirmSent) {
return (
<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">
We sent a confirmation link to <span className="font-medium">{email}</span>. You'll need to confirm
before you can sign in.
</p>
{import.meta.env.DEV && (
<p className="mt-2 text-[11px] text-slate-400">
Local dev: open Mailpit at{' '}
<a href="http://127.0.0.1:54324" className="underline" target="_blank" rel="noreferrer">
127.0.0.1:54324
</a>{' '}
to see it — no real email is sent.
</p>
)}
<button
onClick={() => {
setConfirmSent(false)
setMode('sign-in')
}}
className="mt-4 text-xs font-medium text-indigo-600 hover:underline"
>
Back to sign in
</button>
</div>
</div>
)
}
return (
<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>
<button
type="button"
onClick={handleGoogleSignIn}
disabled={loading}
className="mt-4 flex w-full items-center justify-center gap-2 rounded border border-slate-300 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg viewBox="0 0 18 18" className="h-4 w-4" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.9c1.7-1.57 2.7-3.88 2.7-6.62z" />
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.9-2.26c-.8.54-1.83.86-3.06.86-2.35 0-4.34-1.59-5.05-3.72H.96v2.33A9 9 0 0 0 9 18z" />
<path fill="#FBBC05" d="M3.95 10.7A5.4 5.4 0 0 1 3.67 9c0-.59.1-1.17.28-1.7V4.96H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.04z" />
<path fill="#EA4335" d="M9 3.58c1.32 0 2.51.46 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.96L3.95 7.3C4.66 5.17 6.65 3.58 9 3.58z" />
</svg>
Continue with Google
</button>
<div className="my-4 flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-400">
<div className="h-px flex-1 bg-slate-200" />
or
<div className="h-px flex-1 bg-slate-200" />
</div>
<div className="space-y-3">
{mode === 'sign-up' && (
<label className="block text-xs font-medium text-slate-600">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
)}
<label className="block text-xs font-medium text-slate-600">
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs font-medium text-slate-600">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
</div>
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="mt-4 w-full rounded bg-indigo-600 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Please wait…' : mode === 'sign-in' ? 'Sign in' : 'Create account'}
</button>
<button
type="button"
onClick={() => {
setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in')
setError(null)
}}
className="mt-3 w-full text-center text-xs text-slate-500 hover:underline"
>
{mode === 'sign-in' ? "Don't have an account? Create one" : 'Already have an account? Sign in'}
</button>
</form>
</div>
)
}
+106 -7
View File
@@ -1,10 +1,33 @@
import { useMemo } from 'react' import { Fragment, useMemo, useState } from 'react'
import { computeBom } from '../../domain/bom' import { computeBom } from '../../domain/bom'
import { useProjectStore } from '../../state/projectStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import Chevron from '../common/Chevron'
function formatCost(value: number): string {
return `$${value.toFixed(2)}`
}
export default function BomPanel() { export default function BomPanel() {
const project = useProjectStore((s) => s.project) const diagram = useDiagramStore((s) => s.diagram)
const bom = useMemo(() => computeBom(project), [project]) const catalog = useCatalogStore((s) => s.catalog)
const bom = useMemo(() => computeBom(diagram, catalog), [diagram, catalog])
// Collapsed by default, per cable type — most diagrams only need the
// per-type totals; the length breakdown is there when you're ready to
// actually go buy cables.
const [expandedCableTypeIds, setExpandedCableTypeIds] = useState<Set<string>>(new Set())
const toggleCableType = (cableTypeId: string) => {
setExpandedCableTypeIds((prev) => {
const next = new Set(prev)
if (next.has(cableTypeId)) next.delete(cableTypeId)
else next.add(cableTypeId)
return next
})
}
const hasMissingCost =
bom.devices.some((line) => line.devicesMissingCost > 0) || bom.cables.some((line) => line.connectionsMissingCost > 0)
return ( return (
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
@@ -14,6 +37,13 @@ export default function BomPanel() {
<p className="text-xs italic text-slate-400">No devices on the canvas yet.</p> <p className="text-xs italic text-slate-400">No devices on the canvas yet.</p>
) : ( ) : (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
<th className="pb-1 font-medium">Device</th>
<th className="pb-1 text-right font-medium">Count</th>
<th className="pb-1 text-right font-medium">Cost</th>
</tr>
</thead>
<tbody> <tbody>
{bom.devices.map((line) => ( {bom.devices.map((line) => (
<tr key={line.key} className="border-b border-slate-100 last:border-0"> <tr key={line.key} className="border-b border-slate-100 last:border-0">
@@ -26,6 +56,14 @@ export default function BomPanel() {
)} )}
</td> </td>
<td className="py-1 text-right font-medium text-slate-600">×{line.count}</td> <td className="py-1 text-right font-medium text-slate-600">×{line.count}</td>
<td className="py-1 text-right text-slate-600">
{line.totalCost > 0 ? formatCost(line.totalCost) : '—'}
{line.devicesMissingCost > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some devices are missing a cost">
({line.devicesMissingCost} unset)
</span>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -44,12 +82,29 @@ export default function BomPanel() {
<th className="pb-1 font-medium">Cable</th> <th className="pb-1 font-medium">Cable</th>
<th className="pb-1 text-right font-medium">Count</th> <th className="pb-1 text-right font-medium">Count</th>
<th className="pb-1 text-right font-medium">Total length</th> <th className="pb-1 text-right font-medium">Total length</th>
<th className="pb-1 text-right font-medium">Cost</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bom.cables.map((line) => ( {bom.cables.map((line) => {
<tr key={line.cableTypeId} className="border-b border-slate-100 last:border-0"> const expandable = line.lengths.length > 1
<td className="py-1 pr-2 font-medium text-slate-700">{line.cableTypeName}</td> const expanded = expandable && expandedCableTypeIds.has(line.cableTypeId)
return (
<Fragment key={line.cableTypeId}>
<tr className="border-b border-slate-100 last:border-0">
<td className="py-1 pr-2 font-medium text-slate-700">
{expandable ? (
<button
onClick={() => toggleCableType(line.cableTypeId)}
className="flex items-center gap-1 hover:text-indigo-700"
>
<Chevron expanded={expanded} className="text-slate-400" />
{line.cableTypeName}
</button>
) : (
<span className="pl-[1.1rem]">{line.cableTypeName}</span>
)}
</td>
<td className="py-1 text-right text-slate-600">×{line.count}</td> <td className="py-1 text-right text-slate-600">×{line.count}</td>
<td className="py-1 text-right text-slate-600"> <td className="py-1 text-right text-slate-600">
{line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'} {line.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'}
@@ -59,12 +114,56 @@ export default function BomPanel() {
</span> </span>
)} )}
</td> </td>
<td className="py-1 text-right text-slate-600">
{line.totalCost > 0 ? formatCost(line.totalCost) : '—'}
{line.connectionsMissingCost > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a computable cost">
({line.connectionsMissingCost} unset)
</span>
)}
</td>
</tr>
{expanded &&
line.lengths.map((lengthLine) => (
<tr key={`${line.cableTypeId}-${lengthLine.distance ?? 'unset'}`} className="border-b border-slate-50 last:border-0">
<td className="py-0.5 pl-6 pr-2 text-slate-500">
{lengthLine.distance !== undefined ? `${lengthLine.distance}${line.unit}` : 'No length set'}
</td>
<td className="py-0.5 text-right text-slate-500">×{lengthLine.count}</td>
<td className="py-0.5 text-right text-slate-500">
{lengthLine.totalLength > 0 ? `${lengthLine.totalLength}${line.unit}` : '—'}
</td>
<td className="py-0.5 text-right text-slate-500">
{lengthLine.totalCost > 0 ? formatCost(lengthLine.totalCost) : '—'}
{lengthLine.connectionsMissingCost > 0 && (
<span className="ml-1 text-[10px] text-amber-500" title="Some cables are missing a computable cost">
({lengthLine.connectionsMissingCost} unset)
</span>
)}
</td>
</tr> </tr>
))} ))}
</Fragment>
)
})}
</tbody> </tbody>
</table> </table>
)} )}
</section> </section>
{(bom.devices.length > 0 || bom.cables.length > 0) && (
<section className="border-t border-slate-200 pt-2">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold uppercase tracking-wide text-slate-500">Grand total</span>
<span className="font-semibold text-slate-700">{formatCost(bom.grandTotalCost)}</span>
</div>
{hasMissingCost && (
<p className="mt-0.5 text-[10px] text-amber-500">
Some items are missing a cost and are excluded from this total.
</p>
)}
</section>
)}
</div> </div>
) )
} }
+40 -4
View File
@@ -4,6 +4,26 @@ export type CableEdgeData = {
cableTypeName: string cableTypeName: string
distance?: number distance?: number
unit: string unit: string
/** Set when this edge stands in for more than one real connection between
* the same two (collapsed) devices of the same cable type —
* organized-ideas.md §4's "virtual cable". A per-connection distance
* doesn't generalize across the group, so it's omitted whenever this is
* set; expand either device to see the real per-cable lengths instead. */
aggregateCount?: number
/** Set when either endpoint's port has a built-in/captive cable — the BOM
* omits this connection entirely (nothing to buy), but the wiring is
* still real, so it stays on the canvas with its own marker instead of
* looking identical to a connection that needs a cable purchased. Never
* true at the same time as aggregateCount>1 — FlowCanvas never merges a
* built-in connection with anything else. */
builtIn?: boolean
/** Set when this leg is part of a CableBundle (a stereo pair, a snake) —
* never true alongside aggregateCount or builtIn, since a bundled
* connection never merges with anything (see FlowCanvas). Colors the
* edge and swaps the label to the bundle's own name, so every leg of the
* same physical cable reads as visually one thing. */
bundleName?: string
bundleColor?: string
} }
export type CableFlowEdge = Edge<CableEdgeData, 'cable'> export type CableFlowEdge = Edge<CableEdgeData, 'cable'>
@@ -29,8 +49,15 @@ export default function CableEdge({
targetPosition, targetPosition,
}) })
const isAggregate = !!data?.aggregateCount && data.aggregateCount > 1
const isBuiltIn = !!data?.builtIn
const bundleColor = data?.bundleName ? data.bundleColor : undefined
const label = data const label = data
? `${data.cableTypeName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}` ? bundleColor
? `${data.bundleName}${typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''}`
: `${data.cableTypeName}${
isAggregate ? ` ×${data.aggregateCount}` : typeof data.distance === 'number' ? ` · ${data.distance}${data.unit}` : ''
}${isBuiltIn ? ' · Built-in' : ''}`
: '' : ''
return ( return (
@@ -39,13 +66,22 @@ export default function CableEdge({
id={id} id={id}
path={edgePath} path={edgePath}
markerEnd={markerEnd} markerEnd={markerEnd}
style={{ stroke: selected ? '#6366f1' : '#94a3b8', strokeWidth: selected ? 2.5 : 1.5 }} style={{
stroke: selected ? '#6366f1' : bundleColor ?? (isBuiltIn ? '#64748b' : '#94a3b8'),
strokeWidth: selected ? 2.5 : 1.5,
strokeDasharray: isAggregate ? '6 3' : undefined,
}}
/> />
{label && ( {label && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
className="nodrag nopan absolute rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-600 shadow-sm" className={`nodrag nopan absolute rounded border px-1.5 py-0.5 text-[10px] shadow-sm ${
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }} bundleColor ? '' : isAggregate ? 'border-indigo-200 bg-indigo-50 text-indigo-700' : 'border-slate-200 bg-white text-slate-600'
}`}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
...(bundleColor ? { borderColor: bundleColor, color: bundleColor, backgroundColor: 'white' } : {}),
}}
> >
{label} {label}
</div> </div>
+137 -11
View File
@@ -1,6 +1,7 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react' import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { Device, Port, PortType } from '../../domain/types' import type { Device, Port, PortDirection, PortType } from '../../domain/types'
import { buildHandleId } from './handleIds' import Chevron from '../common/Chevron'
import { buildGroupHandleId, buildHandleId } from './handleIds'
export interface PortInfo { export interface PortInfo {
port: Port port: Port
@@ -14,7 +15,14 @@ export type DeviceNodeData = {
/** Resolved from the device's category id — see FlowCanvas — since a /** Resolved from the device's category id — see FlowCanvas — since a
* custom category's id is an opaque uuid, not something to show raw. */ * custom category's id is an opaque uuid, not something to show raw. */
categoryName: string categoryName: string
/** Resolved from the device's manufacturerId, same reasoning as
* categoryName — undefined when no manufacturer is set. */
manufacturerName: string | undefined
portInfos: PortInfo[] portInfos: PortInfo[]
/** Per-device compact/expanded toggle (organized-ideas.md §4) — local UI
* state in FlowCanvas, not persisted, defaulting to compact. */
expanded: boolean
onToggleExpanded: () => void
} }
export type DeviceFlowNode = Node<DeviceNodeData, 'device'> export type DeviceFlowNode = Node<DeviceNodeData, 'device'>
@@ -56,27 +64,134 @@ function PortHandle({ portInfo, position }: { portInfo: PortInfo; position: Posi
) )
} }
/** The single attachment point a compact device exposes for one direction
* group — every real connection that would otherwise land on one of this
* group's individual ports attaches here instead (see FlowCanvas's edge
* computation). Always connectable; "full" doesn't apply to a group. */
function GroupHandle({ direction, position }: { direction: PortDirection; position: Position }) {
// Absolutely positioned against the nearest `relative` ancestor (each
// caller below marks its row `relative` for this), with an explicit
// centering transform — rather than relying on React Flow's own
// `.react-flow__handle-*` classes to center it, since our layouts (a
// horizontal input/output row, a vertical bidirectional stack) don't
// match the single-port layout those classes assume, and partially
// overriding them via inline style leaves some of their rules (e.g. a
// stale `left: 50%`) still in effect with nothing to counter them.
const edgeStyle =
position === Position.Left
? { left: -13, top: '50%', transform: 'translateY(-50%)' }
: position === Position.Right
? { right: -13, top: '50%', transform: 'translateY(-50%)' }
: { bottom: -13, left: '50%', transform: 'translateX(-50%)' }
return (
<Handle
type="source"
position={position}
id={buildGroupHandleId(direction)}
// A group handle only exists so real edges have somewhere to land while
// this device is collapsed — dragging a *new* wire from it has no
// single port to attach to, so making one requires expanding first
// rather than silently failing connection validation.
isConnectableStart={false}
isConnectableEnd={false}
style={{
position: 'absolute',
...edgeStyle,
width: 10,
height: 10,
background: '#94a3b8', // slate-400 — neutral, since a group mixes connected/open ports
}}
/>
)
}
function ExpandToggle({ expanded, onToggle }: { expanded: boolean; onToggle: () => void }) {
return (
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
title={expanded ? 'Collapse' : 'Expand'}
className="nodrag shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<Chevron expanded={expanded} />
</button>
)
}
/** "3 connected · 2 open" — omits either half when zero, so a fully-open or
* fully-connected group doesn't read as "0 connected · 3 open" noise. */
function GroupSummary({ portInfos }: { portInfos: PortInfo[] }) {
const connected = portInfos.filter((p) => p.connectionCount > 0).length
const open = portInfos.length - connected
const parts: string[] = []
if (connected > 0) parts.push(`${connected} connected`)
if (open > 0) parts.push(`${open} open`)
return <span className="text-[10px] text-slate-400">{parts.join(' · ')}</span>
}
function CountBadge({ count }: { count: number }) { function CountBadge({ count }: { count: number }) {
if (count === 0) return null if (count === 0) return null
return <span className="shrink-0 rounded bg-orange-50 px-1 text-[10px] text-orange-800">{count}</span> return <span className="shrink-0 rounded bg-orange-50 px-1 text-[10px] text-orange-800">{count}</span>
} }
export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>) { function CompactDeviceNode({ data }: { data: DeviceNodeData }) {
const { device, categoryName, portInfos } = data const { device, portInfos, expanded, onToggleExpanded } = data
const inputs = portInfos.filter((p) => p.port.direction === 'input') const inputs = portInfos.filter((p) => p.port.direction === 'input')
const outputs = portInfos.filter((p) => p.port.direction === 'output') const outputs = portInfos.filter((p) => p.port.direction === 'output')
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional') const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
return ( return (
<div <div className="w-56 rounded-lg border border-slate-300 bg-white shadow-sm">
className={`w-72 rounded-lg border bg-white shadow-sm transition-shadow ${ <div className="flex items-center gap-1.5 rounded-lg px-3 py-2">
selected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-slate-300' <ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
}`} <div className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{device.name}</div>
> </div>
<div className="rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2"> {(inputs.length > 0 || outputs.length > 0 || bidirectional.length > 0) && (
<div className="space-y-1 border-t border-slate-100 px-3 py-2">
{inputs.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<GroupHandle direction="input" position={Position.Left} />
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">In</span>
<GroupSummary portInfos={inputs} />
</div>
)}
{outputs.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">Out</span>
<GroupSummary portInfos={outputs} />
<GroupHandle direction="output" position={Position.Right} />
</div>
)}
{bidirectional.length > 0 && (
<div className="relative flex items-center justify-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-400">I/O</span>
<GroupSummary portInfos={bidirectional} />
<GroupHandle direction="bidirectional" position={Position.Bottom} />
</div>
)}
</div>
)}
</div>
)
}
function ExpandedDeviceNode({ data }: { data: DeviceNodeData }) {
const { device, categoryName, manufacturerName, portInfos, expanded, onToggleExpanded } = data
const inputs = portInfos.filter((p) => p.port.direction === 'input')
const outputs = portInfos.filter((p) => p.port.direction === 'output')
const bidirectional = portInfos.filter((p) => p.port.direction === 'bidirectional')
return (
<div className="w-72 rounded-lg border border-slate-300 bg-white shadow-sm">
<div className="flex items-start gap-1.5 rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
<ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-slate-800">{device.name}</div> <div className="truncate text-sm font-semibold text-slate-800">{device.name}</div>
<div className="truncate text-[11px] text-slate-500"> <div className="truncate text-[11px] text-slate-500">
{[device.manufacturer, device.model].filter(Boolean).join(' ') || categoryName} {[manufacturerName, device.model].filter(Boolean).join(' ') || categoryName}
</div>
</div> </div>
</div> </div>
@@ -131,3 +246,14 @@ export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>
</div> </div>
) )
} }
export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>) {
return (
<div
className={`transition-shadow ${selected ? 'rounded-lg ring-2 ring-indigo-200' : ''}`}
style={selected ? { boxShadow: '0 0 0 1px #6366f1' } : undefined}
>
{data.expanded ? <ExpandedDeviceNode data={data} /> : <CompactDeviceNode data={data} />}
</div>
)
}
+298 -64
View File
@@ -3,6 +3,7 @@ import {
ConnectionMode, ConnectionMode,
Controls, Controls,
MiniMap, MiniMap,
Panel,
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
useReactFlow, useReactFlow,
@@ -13,41 +14,95 @@ import {
type NodeTypes, type NodeTypes,
} from '@xyflow/react' } from '@xyflow/react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useState } from 'react'
import { validateConnection } from '../../domain/compatibility' import { validateConnection } from '../../domain/compatibility'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/project' import {
import { useProjectStore } from '../../state/projectStore' allCableTypes,
allPortTypes,
cableTypesForConnection,
deviceCategoryName,
getPortType,
manufacturerName,
} 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 CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode' import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildHandleId, parsePortId } from './handleIds' import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template' export const TEMPLATE_DRAG_MIME = 'application/x-av-planner-template'
const nodeTypes: NodeTypes = { device: DeviceNode } const nodeTypes: NodeTypes = { device: DeviceNode }
const edgeTypes: EdgeTypes = { cable: CableEdge } const edgeTypes: EdgeTypes = { cable: CableEdge }
function FlowCanvasInner() { // A virtual (merged) edge's id carries the real connection ids it stands in
const project = useProjectStore((s) => s.project) // for, so removing or selecting it can be translated back to the underlying
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId) // domain objects without a parallel id→group lookup table.
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId) const VIRTUAL_EDGE_PREFIX = 'virtual:'
const updateDevicePosition = useProjectStore((s) => s.updateDevicePosition) function connectionIdsForEdge(edgeId: string): string[] {
const removeDevice = useProjectStore((s) => s.removeDevice) return edgeId.startsWith(VIRTUAL_EDGE_PREFIX) ? edgeId.slice(VIRTUAL_EDGE_PREFIX.length).split(',') : [edgeId]
const removeConnection = useProjectStore((s) => s.removeConnection) }
const addConnection = useProjectStore((s) => s.addConnection)
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const selectDevice = useProjectStore((s) => s.selectDevice)
const selectConnection = useProjectStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow()
const portTypes = useMemo(() => allPortTypes(project), [project]) // Deterministic per-bundle color (cycling through a fixed palette by the
const cableTypes = useMemo(() => allCableTypes(project), [project]) // bundle's position in the diagram) so every leg of the same stereo
// pair/snake reads as visually one thing on the canvas — see CableEdge.
const BUNDLE_EDGE_COLORS = ['#d97706', '#059669', '#0284c7', '#c026d3', '#e11d48', '#0d9488', '#ea580c', '#7c3aed']
function bundleColorFor(bundleId: string, bundles: { id: string }[]): string {
const index = bundles.findIndex((b) => b.id === bundleId)
return BUNDLE_EDGE_COLORS[Math.max(index, 0) % BUNDLE_EDGE_COLORS.length]
}
function FlowCanvasInner() {
const diagram = useDiagramStore((s) => s.diagram)
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
const selectedConnectionId = useDiagramStore((s) => s.selectedConnectionId)
const updateDevicePosition = useDiagramStore((s) => s.updateDevicePosition)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const addConnection = useDiagramStore((s) => s.addConnection)
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const selectDevice = useDiagramStore((s) => s.selectDevice)
const selectConnection = useDiagramStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow()
const catalog = useCatalogStore((s) => s.catalog)
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
// '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
// anything persisted: not on the device, not in localStorage. Every device
// starts compact (absent from the set); membership means "expanded".
// Toggling itself is always allowed even in a view-only diagram, since it
// doesn't touch diagram content — only `canEdit`-gated actions below do.
const [expandedDeviceIds, setExpandedDeviceIds] = useState<Set<string>>(new Set())
const toggleExpanded = useCallback((id: string) => {
setExpandedDeviceIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const expandAll = useCallback(
() => setExpandedDeviceIds(new Set(diagram.devices.map((d) => d.id))),
[diagram.devices],
)
const collapseAll = useCallback(() => setExpandedDeviceIds(new Set()), [])
const portTypes = useMemo(() => allPortTypes(catalog), [catalog])
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
const nodes: DeviceFlowNode[] = useMemo( const nodes: DeviceFlowNode[] = useMemo(
() => () =>
project.devices.map((device) => { diagram.devices.map((device) => {
const portInfos: PortInfo[] = device.ports.map((port) => { const portInfos: PortInfo[] = device.ports.map((port) => {
const portType = portTypes.find((pt) => pt.id === port.portTypeId) const portType = portTypes.find((pt) => pt.id === port.portTypeId)
const connectionCount = project.connections.filter( const connectionCount = diagram.connections.filter(
(c) => c.sourcePortId === port.id || c.targetPortId === port.id, (c) => c.sourcePortId === port.id || c.targetPortId === port.id,
).length ).length
const max = portType?.maxConnections ?? 1 const max = portType?.maxConnections ?? 1
@@ -58,65 +113,207 @@ function FlowCanvasInner() {
type: 'device', type: 'device',
position: device.position, position: device.position,
selected: device.id === selectedDeviceId, selected: device.id === selectedDeviceId,
data: { device, categoryName: deviceCategoryName(project, device.category), portInfos },
}
}),
[project, portTypes, selectedDeviceId],
)
const edges: CableFlowEdge[] = useMemo(
() =>
project.connections.map((connection) => {
const cableType = cableTypes.find((ct) => ct.id === connection.cableTypeId)
return {
id: connection.id,
type: 'cable',
source: connection.sourceDeviceId,
sourceHandle: buildHandleId(connection.sourcePortId),
target: connection.targetDeviceId,
targetHandle: buildHandleId(connection.targetPortId),
selected: connection.id === selectedConnectionId,
data: { data: {
cableTypeName: cableType?.name ?? 'Unknown Cable', device,
distance: connection.distance, categoryName: deviceCategoryName(catalog, device.category),
unit: cableType?.unit ?? 'ft', manufacturerName: manufacturerName(catalog, device.manufacturerId),
portInfos,
expanded: expandedDeviceIds.has(device.id),
onToggleExpanded: () => toggleExpanded(device.id),
}, },
} }
}), }),
[project.connections, cableTypes, selectedConnectionId], [diagram, catalog, portTypes, selectedDeviceId, expandedDeviceIds, toggleExpanded],
) )
// Every real connection always gets its own domain object and its own BOM
// line (domain/bom.ts reads diagram.connections directly and never sees
// this component) — what collapses here is purely which handles an edge
// renders between. A connection whose device is expanded attaches to that
// device's real port handle as before; a connection whose device is
// collapsed attaches to that device's one handle for the port's direction
// instead. When *both* ends of two or more connections resolve to the same
// pair of (device, handle) points and the same cable type, they're drawn as
// a single dashed "virtual cable" edge with a ×N marker — the merge is
// undone the instant either device is expanded again, since the handles
// stop matching. A connection touching a built-in/captive port (bom.ts
// excludes these from the shopping list entirely) never merges with
// anything, even another built-in one — there's no quantity-to-buy for a
// ×N badge to mean anything about, so it always keeps its own edge.
const edges: CableFlowEdge[] = useMemo(() => {
type Resolved = {
connectionId: string
cableTypeId: string
distance: number | undefined
source: string
sourceHandle: string
target: string
targetHandle: string
builtIn: boolean
bundleId: string | undefined
}
const resolved: Resolved[] = []
for (const connection of diagram.connections) {
const sourceDevice = diagram.devices.find((d) => d.id === connection.sourceDeviceId)
const targetDevice = diagram.devices.find((d) => d.id === connection.targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === connection.sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === connection.targetPortId)
if (!sourceDevice || !targetDevice || !sourcePort || !targetPort) continue
const sourceHandle = expandedDeviceIds.has(sourceDevice.id)
? buildHandleId(sourcePort.id)
: buildGroupHandleId(sourcePort.direction)
const targetHandle = expandedDeviceIds.has(targetDevice.id)
? buildHandleId(targetPort.id)
: buildGroupHandleId(targetPort.direction)
resolved.push({
connectionId: connection.id,
cableTypeId: connection.cableTypeId,
distance: connection.distance,
source: sourceDevice.id,
sourceHandle,
target: targetDevice.id,
targetHandle,
builtIn: !!sourcePort.builtInCable || !!targetPort.builtInCable,
bundleId: connection.bundleId,
})
}
const edges: CableFlowEdge[] = []
// Group by the unordered pair of (device, handle) endpoints + cable type
// — unordered because which real connection got recorded as "source" vs
// "target" is an accident of creation order, not something two cables
// between the same two collapsed devices should be split on.
const groups = new Map<string, Resolved[]>()
for (const r of resolved) {
// A bundled leg never merges either, same reasoning as built-in — it
// needs to keep its own edge so its bundle color/name actually shows,
// and a ×N aggregate badge wouldn't mean anything for cables that
// aren't interchangeable purchases in the first place.
if (r.builtIn || r.bundleId) continue
const a = `${r.source}::${r.sourceHandle}`
const b = `${r.target}::${r.targetHandle}`
const key = [a, b].sort().join('|') + '|' + r.cableTypeId
const group = groups.get(key)
if (group) group.push(r)
else groups.set(key, [r])
}
for (const group of groups.values()) {
const cableType = cableTypes.find((ct) => ct.id === group[0].cableTypeId)
const isAggregate = group.length > 1
const representative = group[0]
edges.push({
id: isAggregate
? `${VIRTUAL_EDGE_PREFIX}${group.map((r) => r.connectionId).join(',')}`
: representative.connectionId,
type: 'cable',
source: representative.source,
sourceHandle: representative.sourceHandle,
target: representative.target,
targetHandle: representative.targetHandle,
selected: !isAggregate && representative.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: isAggregate ? undefined : representative.distance,
unit: cableType?.unit ?? 'ft',
aggregateCount: isAggregate ? group.length : undefined,
},
})
}
for (const r of resolved) {
if (!r.builtIn) continue
const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId)
edges.push({
id: r.connectionId,
type: 'cable',
source: r.source,
sourceHandle: r.sourceHandle,
target: r.target,
targetHandle: r.targetHandle,
selected: r.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: r.distance,
unit: cableType?.unit ?? 'ft',
builtIn: true,
},
})
}
for (const r of resolved) {
if (!r.bundleId) continue
const cableType = cableTypes.find((ct) => ct.id === r.cableTypeId)
const bundle = diagram.bundles.find((b) => b.id === r.bundleId)
edges.push({
id: r.connectionId,
type: 'cable',
source: r.source,
sourceHandle: r.sourceHandle,
target: r.target,
targetHandle: r.targetHandle,
selected: r.connectionId === selectedConnectionId,
data: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: r.distance,
unit: cableType?.unit ?? 'ft',
bundleName: bundle?.name ?? 'Bundle',
bundleColor: bundleColorFor(r.bundleId, diagram.bundles),
},
})
}
return edges
}, [diagram.devices, diagram.connections, diagram.bundles, cableTypes, selectedConnectionId, expandedDeviceIds])
const isValidConnection = useCallback( const isValidConnection = useCallback(
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => { (edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
const c = edgeOrConnection as Connection const c = edgeOrConnection as Connection
const sourcePortId = parsePortId(c.sourceHandle) const sourcePortId = parsePortId(c.sourceHandle)
const targetPortId = parsePortId(c.targetHandle) const targetPortId = parsePortId(c.targetHandle)
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false if (!canEdit || !c.source || !c.target || !sourcePortId || !targetPortId) return false
return validateConnection(project, { return validateConnection(diagram, catalog, {
sourceDeviceId: c.source, sourceDeviceId: c.source,
sourcePortId, sourcePortId,
targetDeviceId: c.target, targetDeviceId: c.target,
targetPortId, targetPortId,
}).valid }).valid
}, },
[project], [diagram, catalog, canEdit],
) )
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!canEdit) return
const sourcePortId = parsePortId(connection.sourceHandle) const sourcePortId = parsePortId(connection.sourceHandle)
const targetPortId = parsePortId(connection.targetHandle) const targetPortId = parsePortId(connection.targetHandle)
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
const sourceDevice = project.devices.find((d) => d.id === connection.source) const sourceDevice = diagram.devices.find((d) => d.id === connection.source)
const targetDevice = project.devices.find((d) => d.id === connection.target) const targetDevice = diagram.devices.find((d) => d.id === connection.target)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId) const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId) const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
const sourcePortType = sourcePort && getPortType(project, sourcePort.portTypeId) const sourcePortType = sourcePort && getPortType(catalog, sourcePort.portTypeId)
const targetPortType = targetPort && getPortType(project, targetPort.portTypeId) const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
const defaultCableType = const defaultCableType =
sourcePortType && targetPortType && cableTypesForConnection(project, sourcePortType, targetPortType)[0] sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0]
if (!defaultCableType) return if (!defaultCableType) {
addConnection({ // isValidConnection above already confirmed the two port *types* are
// compatible (or bridgeable) — reaching here with no cable type
// means compatibility exists on paper but nobody's ever defined an
// actual cable for it, which otherwise fails completely silently
// (the drag just does nothing, with no clue why).
if (sourcePortType && targetPortType) {
window.alert(
`${sourcePortType.name} and ${targetPortType.name} are compatible, but there's no cable type defined for them yet.\n\nAdd one from the device palette's Library ▾ → Connectors → Cable Types tab, with a family matching one of these two, then try again.`,
)
}
return
}
addConnection(catalog, {
sourceDeviceId: connection.source, sourceDeviceId: connection.source,
sourcePortId, sourcePortId,
targetDeviceId: connection.target, targetDeviceId: connection.target,
@@ -124,11 +321,12 @@ function FlowCanvasInner() {
cableTypeId: defaultCableType.id, cableTypeId: defaultCableType.id,
}) })
}, },
[project, addConnection], [diagram, catalog, canEdit, addConnection],
) )
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange<DeviceFlowNode>[]) => { (changes: NodeChange<DeviceFlowNode>[]) => {
if (!canEdit) return
for (const change of changes) { for (const change of changes) {
if (change.type === 'position' && change.position) { if (change.type === 'position' && change.position) {
updateDevicePosition(change.id, change.position) updateDevicePosition(change.id, change.position)
@@ -137,33 +335,40 @@ function FlowCanvasInner() {
} }
} }
}, },
[updateDevicePosition, removeDevice], [canEdit, updateDevicePosition, removeDevice],
) )
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: EdgeChange<CableFlowEdge>[]) => { (changes: EdgeChange<CableFlowEdge>[]) => {
if (!canEdit) return
for (const change of changes) { for (const change of changes) {
if (change.type === 'remove') removeConnection(change.id) // Deleting a merged edge (the ×N marker) removes every real
// connection it represents, not just a display artifact.
if (change.type === 'remove') connectionIdsForEdge(change.id).forEach(removeConnection)
} }
}, },
[removeConnection], [canEdit, removeConnection],
) )
const onDragOver = useCallback((event: React.DragEvent) => { const onDragOver = useCallback(
if (!event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return (event: React.DragEvent) => {
if (!canEdit || !event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
event.preventDefault() event.preventDefault()
event.dataTransfer.dropEffect = 'move' event.dataTransfer.dropEffect = 'move'
}, []) },
[canEdit],
)
const onDrop = useCallback( const onDrop = useCallback(
(event: React.DragEvent) => { (event: React.DragEvent) => {
if (!canEdit) return
const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME) const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME)
if (!templateId) return if (!templateId) return
event.preventDefault() event.preventDefault()
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }) const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
addDeviceFromTemplate(templateId, position) addDeviceFromTemplate(catalog, templateId, position)
}, },
[screenToFlowPosition, addDeviceFromTemplate], [screenToFlowPosition, catalog, canEdit, addDeviceFromTemplate],
) )
return ( return (
@@ -177,9 +382,15 @@ function FlowCanvasInner() {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
nodesDraggable={canEdit}
nodesConnectable={canEdit}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
onNodeClick={(_, node) => selectDevice(node.id)} onNodeClick={(_, node) => selectDevice(node.id)}
onEdgeClick={(_, edge) => selectConnection(edge.id)} onEdgeClick={(_, edge) => {
// A merged edge doesn't correspond to one Connection — expand
// either endpoint to select and edit the individual cables.
if (!edge.id.startsWith(VIRTUAL_EDGE_PREFIX)) selectConnection(edge.id)
}}
onPaneClick={() => { onPaneClick={() => {
selectDevice(null) selectDevice(null)
}} }}
@@ -187,8 +398,31 @@ 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">
<button
onClick={expandAll}
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm hover:bg-slate-50"
>
Expand all
</button>
<button
onClick={collapseAll}
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm hover:bg-slate-50"
>
Collapse all
</button>
</Panel>
</ReactFlow> </ReactFlow>
</div> </div>
) )
+20
View File
@@ -1,3 +1,5 @@
import type { PortDirection } from '../../domain/types'
/** /**
* React Flow handle ids encode which port they belong to. We use a single * React Flow handle ids encode which port they belong to. We use a single
* handle per port (not two stacked source/target handles) combined with * handle per port (not two stacked source/target handles) combined with
@@ -15,3 +17,21 @@ export function parsePortId(handleId: string | null | undefined): string | null
if (!handleId) return null if (!handleId) return null
return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId return handleId.startsWith(PREFIX) ? handleId.slice(PREFIX.length) : handleId
} }
/**
* A compact device node (organized-ideas.md §4) doesn't render individual
* ports, so there's nothing for an edge to attach to at the per-port level —
* instead each direction group (input/output/bidirectional) gets exactly
* one handle, and every real connection touching that device on that side
* attaches there instead. Distinct prefix from port handles so the two
* schemes can never collide.
*/
const GROUP_PREFIX = 'group:'
export function buildGroupHandleId(direction: PortDirection): string {
return `${GROUP_PREFIX}${direction}`
}
export function isGroupHandleId(handleId: string | null | undefined): boolean {
return !!handleId && handleId.startsWith(GROUP_PREFIX)
}
+15 -8
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { allDeviceCategories } from '../../domain/project' import { allDeviceCategories } from '../../domain/diagram'
import { useProjectStore } from '../../state/projectStore' import { useCatalogStore } from '../../state/catalogStore'
const NEW_CATEGORY_VALUE = '__new__' const NEW_CATEGORY_VALUE = '__new__'
@@ -11,25 +11,32 @@ export default function CategorySelect({
value, value,
onChange, onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm', className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: { }: {
value: string value: string
onChange: (categoryId: string) => void onChange: (categoryId: string) => void
className?: string className?: string
/** When true, a category created here goes straight into the public
* catalog (Admin-only) instead of your own private one — used when the
* containing form is itself publishing directly, so a brand-new category
* it references doesn't end up private and invisible to everyone else. */
publishPublic?: boolean
}) { }) {
const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useProjectStore((s) => s.addCustomDeviceCategory) const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('') const [draftName, setDraftName] = useState('')
const categories = useMemo( const categories = useMemo(
() => [...allDeviceCategories(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allDeviceCategories(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
const commitNewCategory = () => { const commitNewCategory = async () => {
const trimmed = draftName.trim() const trimmed = draftName.trim()
if (trimmed) { if (trimmed) {
onChange(addCustomDeviceCategory(trimmed)) onChange(await (publishPublic ? adminAddDeviceCategory(trimmed) : addCustomDeviceCategory(trimmed)))
} }
setCreating(false) setCreating(false)
setDraftName('') setDraftName('')
+16
View File
@@ -0,0 +1,16 @@
/** Thin-stroke disclosure chevron, rotating in place to point down when
* expanded. A crisp line icon reads much more consistent with the rest of
* the UI's small action glyphs (✕ ✎ ⧉ +) than a filled unicode triangle,
* which looks bold/blocky and doesn't align cleanly at small sizes. */
export default function Chevron({ expanded, className = '' }: { expanded: boolean; className?: string }) {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className={`h-3 w-3 shrink-0 transition-transform duration-150 ${expanded ? 'rotate-90' : ''} ${className}`}
>
<path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Chevron from './Chevron'
/**
* A click-toggled dropdown menu for grouping several TopBar actions behind
* one trigger — closes on an outside click, or automatically when any menu
* item is clicked (via bubbling, so individual items don't each need their
* own "and now close the menu" call).
*/
export default function DropdownMenu({
label,
align = 'left',
children,
}: {
label: ReactNode
align?: 'left' | 'right'
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onOutsideClick)
return () => document.removeEventListener('mousedown', onOutsideClick)
}, [open])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
{label}
<Chevron expanded={open} className="text-slate-400" />
</button>
{open && (
<div
onClick={() => setOpen(false)}
className={`absolute top-full z-30 mt-1 min-w-[190px] rounded border border-slate-200 bg-white py-1 shadow-lg ${
align === 'right' ? 'right-0' : 'left-0'
}`}
>
{children}
</div>
)}
</div>
)
}
/** A row inside a DropdownMenu — same look everywhere so items from
* different menus (Diagram, Admin, Account) don't feel inconsistent. */
export function DropdownMenuItem({
onClick,
children,
badge,
}: {
onClick: () => void
children: ReactNode
badge?: ReactNode
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-xs text-slate-600 hover:bg-slate-100"
>
<span>{children}</span>
{badge}
</button>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useMemo, useState } from 'react'
import { familyLabel } from '../../domain/diagram'
import type { PortType } from '../../domain/types'
import InfoTooltip from './InfoTooltip'
const NEW_FAMILY_VALUE = '__new__'
const FAMILY_HELP_TEXT =
'A connector family groups together connectors that are physically and electrically identical, so the same cables fit them all with no adapter needed. Pick an existing family to match one exactly; use "+ New family…" only for a genuinely different connector. For one that mates via an adapter cable instead, use "Can accept connections from" below.'
/**
* A compatibility-family picker for defining/editing a PortType. `family` is
* an opaque matching key (two ports connect only if their families are the
* same string), which used to be a plain free-text field — impossible to
* know what to type to deliberately match an *existing* family (is it
* "HDMI", "hdmi", "Hdmi 2.1"?) without reading source. This instead lists
* every family already in use, labeled by the port type that owns it (see
* familyLabel), so matching an existing one is a pick, not a guess — with a
* "+ New family…" escape hatch (free text) for a genuinely new one, same
* inline-create shape as CategorySelect/ManufacturerSelect.
*
* Unlike those two, there's no backend entity to insert here — family is
* just a string on the PortType being saved — so "new" mode only needs a
* local draft and a plain callback, no async create step.
*/
export default function FamilySelect({
value,
onChange,
portTypes,
className = 'w-full rounded border border-slate-300 px-1.5 py-1 text-xs',
}: {
value: string
onChange: (family: string) => void
portTypes: PortType[]
className?: string
}) {
const families = useMemo(() => {
const seen = new Set<string>()
const entries: { family: string; label: string }[] = []
for (const pt of portTypes) {
if (seen.has(pt.family)) continue
seen.add(pt.family)
entries.push({ family: pt.family, label: familyLabel(portTypes, pt.family) })
}
return entries.sort((a, b) => a.label.localeCompare(b.label))
}, [portTypes])
// Defaults to the free-text box when the current value isn't one of the
// known families — covers both "brand new port type, nothing typed yet"
// (value is '') and "editing a type whose family predates this picker".
// An existing type's own family is always itself in `families` (it's one
// of `portTypes`), so this never spuriously hides a valid current value.
const [creating, setCreating] = useState(() => value === '' || !families.some((f) => f.family === value))
const [draft, setDraft] = useState(value)
const commitDraft = () => {
const trimmed = draft.trim()
if (trimmed) onChange(trimmed)
setCreating(false)
}
const label = (
<div className="mb-1 flex items-center gap-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-slate-500">Family</span>
<InfoTooltip text={FAMILY_HELP_TEXT} />
</div>
)
if (creating) {
return (
<div>
{label}
<div className="flex gap-1.5">
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitDraft()
} else if (e.key === 'Escape' && families.length > 0) {
setCreating(false)
}
}}
placeholder="New family (e.g. hdmi)"
className={`min-w-0 flex-1 ${className}`}
/>
<button
onClick={commitDraft}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Use
</button>
{families.length > 0 && (
<button
onClick={() => setCreating(false)}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Choose existing
</button>
)}
</div>
</div>
)
}
return (
<div>
{label}
<select
value={value}
onChange={(e) => {
if (e.target.value === NEW_FAMILY_VALUE) {
setDraft('')
setCreating(true)
} else {
onChange(e.target.value)
}
}}
className={className}
>
{families.map((f) => (
<option key={f.family} value={f.family}>
{f.label} ({f.family})
</option>
))}
<option value={NEW_FAMILY_VALUE}>+ New family…</option>
</select>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const POPOVER_WIDTH = 256
const VIEWPORT_MARGIN = 8
/**
* A small "i" affordance that reveals a block of help text on click — for a
* concept that needs more than a one-line inline hint can carry, without
* permanently taking up screen space. Click-toggled rather than hover-only
* so it works the same on touch as with a mouse, and stays open long enough
* to actually read instead of vanishing the moment the pointer drifts.
*
* Renders the popover through a portal to `document.body`, positioned via
* the trigger's own bounding rect rather than as a normal absolutely-
* positioned child — this component is mostly used inside a Modal, whose
* scrollable body clips anything (including absolutely-positioned children)
* that pokes past its edge, which cut the popover off before this existed.
*/
export default function InfoTooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const toggle = () => {
if (!open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const left = Math.min(Math.max(rect.left, VIEWPORT_MARGIN), window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN)
setPosition({ top: rect.bottom + 4, left })
}
setOpen((v) => !v)
}
useEffect(() => {
if (!open) return
const onOutsideClick = (e: MouseEvent) => {
const target = e.target as Node
if (buttonRef.current?.contains(target) || popoverRef.current?.contains(target)) return
setOpen(false)
}
// Also close on scroll (e.g. the modal body it's usually opened inside)
// rather than tracking the trigger's position live — simplest way to
// avoid a stale popover that's drifted away from its "i" icon.
const close = () => setOpen(false)
document.addEventListener('mousedown', onOutsideClick)
document.addEventListener('scroll', close, true)
return () => {
document.removeEventListener('mousedown', onOutsideClick)
document.removeEventListener('scroll', close, true)
}
}, [open])
return (
<>
<button
ref={buttonRef}
type="button"
onClick={toggle}
title="More info"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-0 bg-transparent p-0 text-slate-400 hover:text-slate-600"
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 9.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="10" cy="6.5" r="1" fill="currentColor" />
</svg>
</button>
{open &&
position &&
createPortal(
<div
ref={popoverRef}
style={{ position: 'fixed', top: position.top, left: position.left, width: POPOVER_WIDTH }}
className="z-[60] rounded border border-slate-200 bg-white p-2 text-[11px] font-normal normal-case leading-snug text-slate-600 shadow-lg"
>
{text}
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,100 @@
import { useMemo, useState } from 'react'
import { allManufacturers } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
const NEW_MANUFACTURER_VALUE = '__new__'
const NONE_VALUE = ''
/** A manufacturer <select> mirroring CategorySelect's "+ New…" inline-create
* pattern, but optional — unlike a device category, not every device has a
* known manufacturer (DIY/generic gear), so this always offers a blank
* "(none)" option alongside the catalog and the create option. */
export default function ManufacturerSelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: {
value: string | undefined
onChange: (manufacturerId: string | undefined) => void
className?: string
/** See CategorySelect's own doc — same cascade for the same reason. */
publishPublic?: boolean
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
const manufacturers = useMemo(
() => [...allManufacturers(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const commitNewManufacturer = async () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(await (publishPublic ? adminAddManufacturer(trimmed) : addCustomManufacturer(trimmed)))
}
setCreating(false)
setDraftName('')
}
if (creating) {
return (
<div className="mt-1 flex gap-1.5">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitNewManufacturer()
} else if (e.key === 'Escape') {
setCreating(false)
setDraftName('')
}
}}
placeholder="New manufacturer name"
className="w-full min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm"
/>
<button
onClick={commitNewManufacturer}
className="shrink-0 rounded bg-indigo-600 px-2 text-xs font-medium text-white hover:bg-indigo-500"
>
Add
</button>
<button
onClick={() => {
setCreating(false)
setDraftName('')
}}
className="shrink-0 rounded px-2 text-xs text-slate-500 hover:bg-slate-100"
>
Cancel
</button>
</div>
)
}
return (
<select
value={value ?? NONE_VALUE}
onChange={(e) => {
if (e.target.value === NEW_MANUFACTURER_VALUE) setCreating(true)
else onChange(e.target.value === NONE_VALUE ? undefined : e.target.value)
}}
className={className}
>
<option value={NONE_VALUE}>(none)</option>
{manufacturers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
<option value={NEW_MANUFACTURER_VALUE}>+ New manufacturer…</option>
</select>
)
}
+231 -15
View File
@@ -1,29 +1,62 @@
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import { cableTypesForConnection, getPortType } from '../../domain/project' import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useProjectStore } from '../../state/projectStore' import { useCatalogStore } from '../../state/catalogStore'
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 project = useProjectStore((s) => s.project) const diagram = useDiagramStore((s) => s.diagram)
const updateConnection = useProjectStore((s) => s.updateConnection) const catalog = useCatalogStore((s) => s.catalog)
const removeConnection = useProjectStore((s) => s.removeConnection) const canEdit = useCanEdit()
const updateConnection = useDiagramStore((s) => s.updateConnection)
const removeConnection = useDiagramStore((s) => s.removeConnection)
const startBundle = useDiagramStore((s) => s.startBundle)
const addConnectionToBundle = useDiagramStore((s) => s.addConnectionToBundle)
const removeConnectionFromBundle = useDiagramStore((s) => s.removeConnectionFromBundle)
const updateBundle = useDiagramStore((s) => s.updateBundle)
const selectConnection = useDiagramStore((s) => s.selectConnection)
const [startingBundle, setStartingBundle] = useState(false)
const [newBundleName, setNewBundleName] = useState('')
const [newBundleCableTypeId, setNewBundleCableTypeId] = useState('')
const connection = project.connections.find((c) => c.id === connectionId) const connection = diagram.connections.find((c) => c.id === connectionId)
const sourceDevice = project.devices.find((d) => d.id === connection?.sourceDeviceId) const sourceDevice = diagram.devices.find((d) => d.id === connection?.sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === connection?.targetDeviceId) const targetDevice = diagram.devices.find((d) => d.id === connection?.targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === connection?.sourcePortId) const sourcePort = sourceDevice?.ports.find((p) => p.id === connection?.sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === connection?.targetPortId) const targetPort = targetDevice?.ports.find((p) => p.id === connection?.targetPortId)
const availableCableTypes = useMemo(() => { const availableCableTypes = useMemo(() => {
if (!sourcePort || !targetPort) return [] if (!sourcePort || !targetPort) return []
const sourcePortType = getPortType(project, sourcePort.portTypeId) const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId) const targetPortType = getPortType(catalog, targetPort.portTypeId)
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : [] return sourcePortType && targetPortType ? cableTypesForConnection(catalog, sourcePortType, targetPortType) : []
}, [project, sourcePort, targetPort]) }, [catalog, sourcePort, targetPort])
const cableCostPerUnit = availableCableTypes.find((ct) => ct.id === connection?.cableTypeId)?.costPerUnit
// Every cable type, unfiltered by port compatibility — a bundle's own
// cable type is a separate, BOM-only choice from any member's individually
// validated cableTypeId (see CableBundle's own doc comment), so it isn't
// constrained to what fits this specific port pair.
const allBundleCableTypes = useMemo(
() => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const bundle = diagram.bundles.find((b) => b.id === connection?.bundleId)
const otherBundles = diagram.bundles.filter((b) => b.id !== connection?.bundleId)
const otherMembers = diagram.connections.filter((c) => c.bundleId === bundle?.id && c.id !== connectionId)
const describeConnectionEnd = (deviceId: string, portId: string) => {
const device = diagram.devices.find((d) => d.id === deviceId)
const port = device?.ports.find((p) => p.id === portId)
return device && port ? `${device.name} · ${port.name}` : 'Unknown'
}
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
return ( return (
<div className="space-y-3 p-3"> <fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs"> <div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
<div className="flex items-center justify-between py-0.5"> <div className="flex items-center justify-between py-0.5">
<span className="text-slate-500">From</span> <span className="text-slate-500">From</span>
@@ -76,6 +109,30 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
</div> </div>
</label> </label>
<label className="block text-xs font-medium text-slate-600">
Cost
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={connection.cost ?? ''}
onChange={(e) =>
updateConnection(connection.id, {
cost: e.target.value === '' ? undefined : Number(e.target.value),
})
}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder={
cableCostPerUnit !== undefined && connection.distance
? `e.g. ${(cableCostPerUnit * connection.distance).toFixed(2)}`
: 'e.g. 24.99'
}
/>
</div>
</label>
<label className="block text-xs font-medium text-slate-600"> <label className="block text-xs font-medium text-slate-600">
Label Label
<input <input
@@ -96,6 +153,165 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
/> />
</label> </label>
<div className="border-t border-slate-100 pt-3">
<h4 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">Bundle</h4>
{bundle ? (
<div className="space-y-2 rounded border border-indigo-200 bg-indigo-50/40 p-2 text-xs">
<p className="text-slate-500">
Part of a bundle with {otherMembers.length} other cable{otherMembers.length === 1 ? '' : 's'} — counted
as one item on the BOM, using this bundle's own cable type/length/cost below rather than each leg's.
</p>
<label className="block font-medium text-slate-600">
Name
<input
value={bundle.name}
onChange={(e) => updateBundle(bundle.id, { name: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="block font-medium text-slate-600">
Cable type
<select
value={bundle.cableTypeId}
onChange={(e) => updateBundle(bundle.id, { cableTypeId: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
{allBundleCableTypes.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.name}
</option>
))}
</select>
</label>
<div className="flex gap-2">
<label className="block flex-1 font-medium text-slate-600">
Distance
<input
type="number"
min={0}
step={0.5}
value={bundle.distance ?? ''}
onChange={(e) =>
updateBundle(bundle.id, { distance: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
<label className="block flex-1 font-medium text-slate-600">
Cost
<input
type="number"
min={0}
step={0.01}
value={bundle.cost ?? ''}
onChange={(e) =>
updateBundle(bundle.id, { cost: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/>
</label>
</div>
<div>
<p className="font-medium text-slate-600">Other cables in this bundle:</p>
<ul className="mt-1 space-y-0.5">
{otherMembers.map((m) => (
<li key={m.id}>
<button
onClick={() => selectConnection(m.id)}
className="text-left text-indigo-600 hover:underline"
>
{describeConnectionEnd(m.sourceDeviceId, m.sourcePortId)} ↔{' '}
{describeConnectionEnd(m.targetDeviceId, m.targetPortId)}
</button>
</li>
))}
</ul>
</div>
<button
onClick={() => removeConnectionFromBundle(connection.id)}
className="w-full rounded border border-slate-300 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
>
Remove this cable from the bundle
</button>
</div>
) : (
<div className="space-y-2 text-xs">
<p className="text-slate-500">
Group this with other cables that are physically one item — a stereo pair, a snake — so the BOM counts
them as a single purchase instead of one per leg.
</p>
{otherBundles.length > 0 && (
<label className="block font-medium text-slate-600">
Add to an existing bundle
<select
value=""
onChange={(e) => {
if (e.target.value) addConnectionToBundle(connection.id, e.target.value)
}}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
>
<option value="">Choose a bundle…</option>
{otherBundles.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
)}
{!startingBundle ? (
<button
onClick={() => {
setNewBundleName('')
setNewBundleCableTypeId(connection.cableTypeId)
setStartingBundle(true)
}}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ Start a new bundle
</button>
) : (
<div className="space-y-1.5 rounded border border-slate-200 bg-white p-2">
<input
autoFocus
value={newBundleName}
onChange={(e) => setNewBundleName(e.target.value)}
placeholder="e.g. Stereo Pair"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={newBundleCableTypeId}
onChange={(e) => setNewBundleCableTypeId(e.target.value)}
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
>
{allBundleCableTypes.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.name}
</option>
))}
</select>
<div className="flex justify-end gap-2">
<button onClick={() => setStartingBundle(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={() => {
if (!newBundleName.trim() || !newBundleCableTypeId) return
startBundle(connection.id, { name: newBundleName.trim(), cableTypeId: newBundleCableTypeId })
setStartingBundle(false)
}}
disabled={!newBundleName.trim() || !newBundleCableTypeId}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Create
</button>
</div>
</div>
)}
</div>
)}
</div>
<div className="border-t border-slate-100 pt-3"> <div className="border-t border-slate-100 pt-3">
<button <button
onClick={() => removeConnection(connection.id)} onClick={() => removeConnection(connection.id)}
@@ -104,6 +320,6 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
Remove cable Remove cable
</button> </button>
</div> </div>
</div> </fieldset>
) )
} }
+57 -21
View File
@@ -1,33 +1,38 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { PORT_DIRECTIONS } from '../../domain/constants' import { PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes } from '../../domain/project' import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types' import type { PortDirection } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore' import { useCatalogStore } from '../../state/catalogStore'
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'
export default function DeviceInspector({ deviceId }: { deviceId: string }) { export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const project = useProjectStore((s) => s.project) const diagram = useDiagramStore((s) => s.diagram)
const updateDevice = useProjectStore((s) => s.updateDevice) const catalog = useCatalogStore((s) => s.catalog)
const removeDevice = useProjectStore((s) => s.removeDevice) const canEdit = useCanEdit()
const addPort = useProjectStore((s) => s.addPort) const updateDevice = useDiagramStore((s) => s.updateDevice)
const clonePort = useProjectStore((s) => s.clonePort) const removeDevice = useDiagramStore((s) => s.removeDevice)
const updatePort = useProjectStore((s) => s.updatePort) const addPort = useDiagramStore((s) => s.addPort)
const removePort = useProjectStore((s) => s.removePort) const clonePort = useDiagramStore((s) => s.clonePort)
const updatePort = useDiagramStore((s) => s.updatePort)
const removePort = useDiagramStore((s) => s.removePort)
const [focusPortId, setFocusPortId] = useState<string | null>(null) const [focusPortId, setFocusPortId] = useState<string | null>(null)
const device = project.devices.find((d) => d.id === deviceId) const device = diagram.devices.find((d) => d.id === deviceId)
const portTypes = useMemo( const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
if (!device) return null if (!device) return null
const connectionCountForPort = (portId: string) => const connectionCountForPort = (portId: string) =>
project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length diagram.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
return ( return (
<div className="space-y-3 p-3"> <fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<label className="col-span-2 text-xs font-medium text-slate-600"> <label className="col-span-2 text-xs font-medium text-slate-600">
Name Name
@@ -43,13 +48,12 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Manufacturer Manufacturer
<input <ManufacturerSelect
value={device.manufacturer ?? ''} value={device.manufacturerId}
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })} onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/> />
</label> </label>
<label className="col-span-2 text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Model Model
<input <input
value={device.model ?? ''} value={device.model ?? ''}
@@ -57,6 +61,23 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm" className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/> />
</label> </label>
<label className="text-xs font-medium text-slate-600">
Cost
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={device.cost ?? ''}
onChange={(e) =>
updateDevice(device.id, { cost: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. 249.00"
/>
</div>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600"> <label className="col-span-2 text-xs font-medium text-slate-600">
Notes Notes
<textarea <textarea
@@ -74,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
{device.ports.map((port) => { {device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0 const inUse = connectionCountForPort(port.id) > 0
return ( return (
<div key={port.id} className="flex items-center gap-1.5"> <div key={port.id} className="flex flex-wrap items-center gap-1.5">
<input <input
ref={(el) => { ref={(el) => {
if (el && focusPortId === port.id) { if (el && focusPortId === port.id) {
@@ -108,6 +129,20 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</option> </option>
))} ))}
</select> </select>
{/* Grouped so these three wrap to their own line as a unit
(aligned to the row's end) rather than each one reflowing
separately if the row ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(device.id, port.id, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button <button
onClick={() => setFocusPortId(clonePort(device.id, port.id))} onClick={() => setFocusPortId(clonePort(device.id, port.id))}
tabIndex={-1} tabIndex={-1}
@@ -126,6 +161,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
✕ ✕
</button> </button>
</div> </div>
</div>
) )
})} })}
</div> </div>
@@ -153,6 +189,6 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
Delete device Delete device
</button> </button>
</div> </div>
</div> </fieldset>
) )
} }
+56 -9
View File
@@ -1,34 +1,81 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { useProjectStore } from '../../state/projectStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAnnouncementStore } from '../../state/announcementStore'
import { useAuthStore } from '../../state/authStore'
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 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'
export default function AppShell() { export default function AppShell() {
const isLoaded = useProjectStore((s) => s.isLoaded) const isLoaded = useDiagramStore((s) => s.isLoaded)
const loadFromStorage = useProjectStore((s) => s.loadFromStorage) // 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)
const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions)
const loadAuth = useAuthStore((s) => s.load)
const role = useAuthStore((s) => s.role)
const loadAdminQueue = useAdminReviewStore((s) => s.loadAll)
const loadAnnouncement = useAnnouncementStore((s) => s.load)
useEffect(() => { useEffect(() => {
loadFromStorage() loadInitialDiagram()
}, [loadFromStorage]) loadCatalog()
loadMySubmissions()
loadAuth()
loadAnnouncement()
}, [loadInitialDiagram, loadCatalog, loadMySubmissions, loadAuth, loadAnnouncement])
if (!isLoaded) { // Only Admins/Super-Admins need the review queue at all — and role isn't
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div> // known until loadAuth() above resolves, so this is a separate effect
// keyed on it rather than bundled into the one above.
useEffect(() => {
if (role === 'admin' || role === 'super_admin') loadAdminQueue()
}, [role, loadAdminQueue])
if (!isLoaded || !isCatalogLoaded) {
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 />
<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 />
<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>
) )
} }
@@ -1,9 +1,9 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore' import { useDiagramStore } from '../../state/diagramStore'
export default function ConnectionErrorBanner() { export default function ConnectionErrorBanner() {
const error = useProjectStore((s) => s.lastConnectionError) const error = useDiagramStore((s) => s.lastConnectionError)
const clear = useProjectStore((s) => s.clearConnectionError) const clear = useDiagramStore((s) => s.clearConnectionError)
useEffect(() => { useEffect(() => {
if (!error) return if (!error) return
@@ -0,0 +1,95 @@
import { useEffect } from 'react'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
function formatUpdatedAt(iso: string): string {
const date = new Date(iso)
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
export default function DiagramManagerModal({ onClose }: { onClose: () => void }) {
const diagram = useDiagramStore((s) => s.diagram)
const diagramList = useDiagramStore((s) => s.diagramList)
const refreshDiagramList = useDiagramStore((s) => s.refreshDiagramList)
const newDiagram = useDiagramStore((s) => s.newDiagram)
const switchToDiagram = useDiagramStore((s) => s.switchToDiagram)
const deleteDiagram = useDiagramStore((s) => s.deleteDiagram)
useEffect(() => {
refreshDiagramList()
}, [refreshDiagramList])
const handleOpen = async (id: string) => {
if (id === diagram.id) return
await switchToDiagram(id)
onClose()
}
const handleNew = async () => {
await newDiagram()
onClose()
}
const handleDelete = async (id: string, name: string) => {
const confirmed = window.confirm(
diagramList.length === 1
? `Delete "${name}"? This is your only diagram, so a fresh empty one will replace it.`
: `Delete "${name}"? This can't be undone.`,
)
if (!confirmed) return
await deleteDiagram(id)
}
return (
<Modal title="Diagrams" onClose={onClose} width="max-w-lg">
<div className="space-y-3">
<button
onClick={handleNew}
className="w-full rounded bg-indigo-600 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
+ New Diagram
</button>
<ul className="space-y-1">
{diagramList.map((d) => {
const isCurrent = d.id === diagram.id
return (
<li
key={d.id}
className={`group flex items-center justify-between gap-2 rounded border px-3 py-2 text-xs ${
isCurrent ? 'border-indigo-300 bg-indigo-50' : 'border-slate-200 bg-white'
}`}
>
<button
onClick={() => handleOpen(d.id)}
disabled={isCurrent}
className="min-w-0 flex-1 text-left disabled:cursor-default"
>
<div className="flex items-center gap-1.5">
<span className="truncate font-medium text-slate-700">{d.name}</span>
{isCurrent && (
<span className="shrink-0 rounded bg-indigo-100 px-1 text-[10px] font-medium text-indigo-700">
current
</span>
)}
</div>
<div className="truncate text-[10px] text-slate-400">Updated {formatUpdatedAt(d.updatedAt)}</div>
</button>
<button
onClick={() => handleDelete(d.id, d.name)}
title="Delete this diagram"
className="shrink-0 rounded px-1.5 py-0.5 text-slate-400 opacity-0 hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
>
✕
</button>
</li>
)
})}
{diagramList.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">No diagrams yet.</p>
)}
</ul>
</div>
</Modal>
)
}
+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>
)
}
+4 -4
View File
@@ -2,17 +2,17 @@ import { useState } from 'react'
import BomPanel from '../bom/BomPanel' import BomPanel from '../bom/BomPanel'
import ConnectionInspector from '../inspector/ConnectionInspector' import ConnectionInspector from '../inspector/ConnectionInspector'
import DeviceInspector from '../inspector/DeviceInspector' import DeviceInspector from '../inspector/DeviceInspector'
import { useProjectStore } from '../../state/projectStore' import { useDiagramStore } from '../../state/diagramStore'
type Tab = 'inspector' | 'bom' type Tab = 'inspector' | 'bom'
export default function RightPanel() { export default function RightPanel() {
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId) const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId) const selectedConnectionId = useDiagramStore((s) => s.selectedConnectionId)
const [tab, setTab] = useState<Tab>('inspector') const [tab, setTab] = useState<Tab>('inspector')
return ( return (
<aside className="flex h-full w-80 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')}
+161 -32
View File
@@ -1,23 +1,79 @@
import { useRef } from 'react' import { useMemo, useRef, useState } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport' import { downloadDiagramFile, readDiagramFile, DiagramImportError } from '../../data/exportImport'
import { useProjectStore } from '../../state/projectStore' import { supabase } from '../../data/supabaseClient'
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'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
import DiagramSharingModal from '../sharing/DiagramSharingModal'
import VersionHistoryModal from '../sharing/VersionHistoryModal'
import AdminReviewModal from '../submissions/AdminReviewModal'
import MySubmissionsModal from '../submissions/MySubmissionsModal'
import DiagramManagerModal from './DiagramManagerModal'
// When "My Submissions" was last opened — a per-user UI preference (like
// hiddenPublicDeviceTemplateIds in catalogStore), used to badge outcomes
// (approved/rejected) you haven't seen yet without a server-side
// notifications table.
const LAST_SEEN_SUBMISSIONS_KEY = 'av-planner:lastSeenSubmissionsAt'
function getLastSeenSubmissionsAt(): string {
try {
return window.localStorage.getItem(LAST_SEEN_SUBMISSIONS_KEY) ?? ''
} catch {
return ''
}
}
function setLastSeenSubmissionsAt(iso: string): void {
try {
window.localStorage.setItem(LAST_SEEN_SUBMISSIONS_KEY, iso)
} catch {
// Not essential — worst case an already-seen outcome stays badged.
}
}
export default function TopBar() { export default function TopBar() {
const project = useProjectStore((s) => s.project) const diagram = useDiagramStore((s) => s.diagram)
const renameProject = useProjectStore((s) => s.renameProject) const canEdit = useCanEdit()
const newProject = useProjectStore((s) => s.newProject) const renameDiagram = useDiagramStore((s) => s.renameDiagram)
const importProject = useProjectStore((s) => s.importProject) const importDiagram = useDiagramStore((s) => s.importDiagram)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const username = useAuthStore((s) => s.username)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const isSuperAdmin = role === 'super_admin'
const canPostAnnouncements = useAuthStore((s) => s.canPostAnnouncements)
const canManageAnnouncements = isSuperAdmin || (role === 'admin' && canPostAnnouncements)
const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
const [versionHistoryOpen, setVersionHistoryOpen] = useState(false)
const [sharingOpen, setSharingOpen] = useState(false)
const [submissionsOpen, setSubmissionsOpen] = useState(false)
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
const [announcementComposerOpen, setAnnouncementComposerOpen] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const handleNewProject = () => { const pendingSubmissionCount = useMemo(
if (project.devices.length > 0 || project.connections.length > 0) { () => mySubmissions.filter((s) => s.status === 'pending').length,
const confirmed = window.confirm('Start a new project? Unsaved changes to the current one will be lost (unless exported).') [mySubmissions],
if (!confirmed) return )
} const unseenOutcomeCount = useMemo(() => {
newProject() const lastSeen = getLastSeenSubmissionsAt()
return mySubmissions.filter((s) => s.status !== 'pending' && s.updatedAt > lastSeen).length
}, [mySubmissions])
const openMySubmissions = () => {
setSubmissionsOpen(true)
setLastSeenSubmissionsAt(new Date().toISOString())
} }
const handleExport = () => downloadProjectFile(project) const handleExport = () => downloadDiagramFile(diagram)
const handleImportClick = () => fileInputRef.current?.click() const handleImportClick = () => fileInputRef.current?.click()
@@ -26,39 +82,112 @@ export default function TopBar() {
event.target.value = '' event.target.value = ''
if (!file) return if (!file) return
try { try {
const imported = await readProjectFile(file) const imported = await readDiagramFile(file)
importProject(imported) await importDiagram(imported)
} catch (err) { } catch (err) {
const message = err instanceof ProjectImportError ? err.message : 'Could not read that file.' const message = err instanceof DiagramImportError ? err.message : 'Could not read that file.'
window.alert(`Import failed: ${message}`) window.alert(`Import failed: ${message}`)
} }
} }
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">AV Planner</span> <span className="shrink-0 text-sm font-semibold text-indigo-700">Diagrav</span>
<input <input
value={project.name} value={diagram.name}
onChange={(e) => renameProject(e.target.value)} onChange={(e) => renameDiagram(e.target.value)}
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={!canEdit}
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">
<button onClick={handleNewProject} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
New
</button>
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Import
</button>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button <button
onClick={handleExport} onClick={() => setDiagramManagerOpen(true)}
className="rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-500" className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
> >
Export 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">
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportClick}>Import</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>Export</DropdownMenuItem>
</DropdownMenu>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
My Submissions
{unseenOutcomeCount > 0 ? (
<span
className="ml-1 rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700"
title="Submission(s) reviewed since you last checked"
>
{unseenOutcomeCount}
</span>
) : (
pendingSubmissionCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{pendingSubmissionCount}
</span>
)
)}
</button>
{isAdmin && (
<DropdownMenu
align="right"
label={
<>
Admin
{adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)}
</>
}
>
<DropdownMenuItem
onClick={() => setAdminReviewOpen(true)}
badge={
adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)
}
>
Review Queue
</DropdownMenuItem>
{isSuperAdmin && <DropdownMenuItem onClick={() => setAdminUsersOpen(true)}>Manage Users</DropdownMenuItem>}
{canManageAnnouncements && (
<DropdownMenuItem onClick={() => setAnnouncementComposerOpen(true)}>Announcement</DropdownMenuItem>
)}
</DropdownMenu>
)}
</div> </div>
<DropdownMenu label={username ?? 'Account'} align="right">
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
<DropdownMenuItem onClick={() => supabase.auth.signOut()}>Sign out</DropdownMenuItem>
</DropdownMenu>
</div>
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
{sharingOpen && <DiagramSharingModal onClose={() => setSharingOpen(false)} />}
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
{announcementComposerOpen && <AnnouncementComposerModal onClose={() => setAnnouncementComposerOpen(false)} />}
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
</header> </header>
) )
} }
+34
View File
@@ -0,0 +1,34 @@
import { useDiagramStore } from '../../state/diagramStore'
import { useIsMobile } from '../../hooks/useIsMobile'
/** 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)
const isMobile = useIsMobile()
const sharedViewOnly = access?.myPermission === 'view'
if (!sharedViewOnly && !isMobile) return null
return (
<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>
)
}
+233 -51
View File
@@ -1,27 +1,28 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { allCableTypes, allPortTypes } from '../../domain/project' import { allCableTypes, allPortTypes, familyLabel } from '../../domain/diagram'
import type { CableType, PortType } from '../../domain/types' import type { CableType, PortType } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore' import { cableTypeToRow } from '../../data/catalogRowMapping'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFormFields'
const UNITS: CableType['unit'][] = ['ft', 'm'] const UNITS: CableType['unit'][] = ['ft', 'm']
/** Best-effort friendly label for a raw family string: the name of whichever export function CableTypeForm({
* port type owns that family, or the raw string if none matches (e.g. a
* family only ever typed into a text field, not backed by a port type). */
function familyLabel(family: string, portTypes: PortType[]): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
}
function CableTypeForm({
initial, initial,
portTypes, portTypes,
showPublishToggle = false,
onCancel, onCancel,
onSave, onSave,
}: { }: {
initial?: CableType initial?: CableType
portTypes: PortType[] portTypes: PortType[]
/** See PortTypeForm's own doc — same "new + Admin" -only publish toggle. */
showPublishToggle?: boolean
onCancel: () => void onCancel: () => void
onSave: (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => void onSave: (fields: CableTypeFormFields, publishPublic: boolean) => void
}) { }) {
const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined const initialEndA = initial ? portTypes.find((pt) => pt.family === initial.family) : undefined
const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined const initialEndB = initial ? portTypes.find((pt) => pt.family === (initial.family2 ?? initial.family)) : undefined
@@ -30,12 +31,23 @@ function CableTypeForm({
const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '') const [endAPortTypeId, setEndAPortTypeId] = useState(initialEndA?.id ?? portTypes[0]?.id ?? '')
const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '') const [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft') const [unit, setUnit] = useState<CableType['unit']>(initial?.unit ?? 'ft')
const [costPerUnit, setCostPerUnit] = useState(initial?.costPerUnit?.toString() ?? '')
const [publishPublic, setPublishPublic] = useState(false)
const isAdapter = endAPortTypeId !== endBPortTypeId const isAdapter = endAPortTypeId !== endBPortTypeId
const handleSave = () => { const handleSave = () => {
if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return if (!name.trim() || !endAPortTypeId || !endBPortTypeId) return
onSave({ name: name.trim(), endAPortTypeId, endBPortTypeId, unit }) onSave(
{
name: name.trim(),
endAPortTypeId,
endBPortTypeId,
unit,
costPerUnit: costPerUnit.trim() === '' ? undefined : Number(costPerUnit),
},
publishPublic,
)
} }
return ( return (
@@ -87,6 +99,25 @@ function CableTypeForm({
? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.' ? 'Different connector on each end — this becomes an adapter cable, letting those two port types wire together even if nothing else declares them compatible.'
: 'Same connector on each end — a regular cable for that connector type.'} : 'Same connector on each end — a regular cable for that connector type.'}
</p> </p>
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[10px] font-medium text-slate-500">Suggested cost/{unit}</span>
<span className="text-xs text-slate-400">$</span>
<input
type="number"
min={0}
step={0.01}
value={costPerUnit}
onChange={(e) => setCostPerUnit(e.target.value)}
placeholder="optional"
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
</div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500"> <button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel Cancel
@@ -103,58 +134,116 @@ function CableTypeForm({
} }
export default function CableTypesPanel() { export default function CableTypesPanel() {
const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog)
const addCustomCableType = useProjectStore((s) => s.addCustomCableType) const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
const updateCustomCableType = useProjectStore((s) => s.updateCustomCableType) const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
const removeCustomCableType = useCatalogStore((s) => s.removeCustomCableType)
const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType)
const adminAddCableType = useCatalogStore((s) => s.adminAddCableType)
const adminRemoveCableType = useCatalogStore((s) => s.adminRemoveCableType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
const portTypes = useMemo( const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
const allTypes = useMemo( const allTypes = useMemo(
() => [...allCableTypes(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes]) const customTypes = useMemo(() => allTypes.filter((ct) => ct.custom), [allTypes])
const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes]) const builtInTypes = useMemo(() => allTypes.filter((ct) => !ct.custom), [allTypes])
const pendingEntityIds = useMemo(
() =>
new Set(
mySubmissions.filter((s) => s.entityType === 'cable_type' && s.status === 'pending').map((s) => s.entityId),
),
[mySubmissions],
)
const describeEnds = (ct: CableType) => { const describeEnds = (ct: CableType) => {
const a = familyLabel(ct.family, portTypes) const a = familyLabel(portTypes, ct.family)
const b = familyLabel(ct.family2 ?? ct.family, portTypes) const b = familyLabel(portTypes, ct.family2 ?? ct.family)
return a === b ? a : `${a} ↔ ${b}` return a === b ? a : `${a} ↔ ${b}`
} }
const handleCreate = (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => { const handleCreate = (fields: CableTypeFormFields, publishPublic: boolean) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId) const resolved = resolveCableTypeFields(fields, portTypes)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId) if (!resolved) return
if (!endA || !endB) return if (isAdmin && publishPublic) adminAddCableType(resolved)
addCustomCableType({ else addCustomCableType(resolved)
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
})
setCreating(false) setCreating(false)
} }
const handleUpdate = ( const handleUpdate = (id: string, fields: CableTypeFormFields) => {
id: string, const resolved = resolveCableTypeFields(fields, portTypes)
fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }, if (!resolved) return
) => { updateCustomCableType(id, resolved)
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return
updateCustomCableType(id, {
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
})
setEditingId(null) setEditingId(null)
} }
const handleSubmitForPublic = (ct: CableType) => {
submitForReview('cable_type', ct.id, cableTypeToRow(ct))
}
const handleSuggestEdit = (id: string, fields: CableTypeFormFields) => {
const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return
submitForReview('cable_type', id, cableTypeToRow(resolved))
setSuggestingId(null)
}
const handleAdminSave = (id: string, fields: CableTypeFormFields) => {
const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return
adminUpdateCableType(id, resolved)
setAdminEditingId(null)
}
const handleUnpublish = async (ct: CableType) => {
const impact = await getUsageImpact('cable_type', ct.id)
const impactNote =
impact.diagramCount > 0
? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.`
: '\n\nNot currently used in any diagram.'
const confirmed = window.confirm(`Unpublish "${ct.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish('cable_type', ct.id)
}
// Shared by Custom (your own) and Built-in (Admin permanent-delete) —
// cable types aren't referenced by any foreign key (matched by family at
// diagram-render time, not by id), so the only usage check needed is the
// same diagram-usage-impact one Unpublish already uses. Blocks outright
// rather than offering "confirm anyway" — unlike unpublish, this can't be
// undone.
const deleteIfUnused = async (ct: CableType, remove: (id: string) => Promise<void>) => {
const impact = await getUsageImpact('cable_type', ct.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${ct.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${ct.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(ct.id)
} catch {
window.alert(`Couldn't delete "${ct.name}" — it may still be in use somewhere.`)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -170,7 +259,12 @@ export default function CableTypesPanel() {
</div> </div>
{creating && ( {creating && (
<div className="mb-1.5"> <div className="mb-1.5">
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} /> <CableTypeForm
portTypes={portTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div> </div>
)} )}
{customTypes.length === 0 && !creating ? ( {customTypes.length === 0 && !creating ? (
@@ -197,14 +291,36 @@ export default function CableTypesPanel() {
<div className="font-medium text-slate-700">{ct.name}</div> <div className="font-medium text-slate-700">{ct.name}</div>
<div className="truncate text-[10px] text-slate-400"> <div className="truncate text-[10px] text-slate-400">
{describeEnds(ct)} · {ct.unit} {describeEnds(ct)} · {ct.unit}
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1">
{pendingEntityIds.has(ct.id) ? (
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
Pending review
</span>
) : (
<button
onClick={() => handleSubmitForPublic(ct)}
title="Submit this to the public catalog for Admin review"
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
>
Submit
</button>
)}
<button <button
onClick={() => setEditingId(ct.id)} onClick={() => setEditingId(ct.id)}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700" className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
> >
Edit Edit
</button> </button>
<button
onClick={() => deleteIfUnused(ct, removeCustomCableType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div> </div>
), ),
)} )}
@@ -216,14 +332,80 @@ export default function CableTypesPanel() {
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500"> <h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length}) Built-in ({builtInTypes.length})
</h4> </h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5"> <div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((ct) => ( {builtInTypes.map((ct) => {
<div key={ct.id} className="px-1 py-0.5 text-[11px] text-slate-500"> if (isAdmin && adminEditingId === ct.id) {
return (
<div key={ct.id} className="mb-1.5">
<CableTypeForm
initial={ct}
portTypes={portTypes}
onCancel={() => setAdminEditingId(null)}
onSave={(fields) => handleAdminSave(ct.id, fields)}
/>
</div>
)
}
if (!isAdmin && suggestingId === ct.id) {
return (
<div key={ct.id} className="mb-1.5">
<CableTypeForm
initial={ct}
portTypes={portTypes}
onCancel={() => setSuggestingId(null)}
onSave={(fields) => handleSuggestEdit(ct.id, fields)}
/>
</div>
)
}
return (
<div key={ct.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
<span className="min-w-0 truncate">
{ct.name} <span className="text-slate-400">· {describeEnds(ct)}</span> {ct.name} <span className="text-slate-400">· {describeEnds(ct)}</span>
</span>
{isAdmin ? (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setAdminEditingId(ct.id)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
<button
onClick={() => handleUnpublish(ct)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Unpublish
</button>
<button
onClick={() => deleteIfUnused(ct, adminRemoveCableType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div> </div>
))} ) : pendingEntityIds.has(ct.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
Edit pending review
</span>
) : (
<button
onClick={() => setSuggestingId(ct.id)}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Suggest edit
</button>
)}
</div> </div>
<p className="mt-1 text-[10px] text-slate-400">Built-in cable types can't be edited or removed.</p> )
})}
</div>
<p className="mt-1 text-[10px] text-slate-400">
{isAdmin
? 'As an Admin, you can edit or unpublish public cable types directly.'
: 'Built-in cable types can\'t be edited directly — "Suggest edit" sends a proposed change to Admins for review.'}
</p>
</div> </div>
</div> </div>
) )
@@ -0,0 +1,44 @@
import { useState } from 'react'
import CategoryManufacturerPanel from './CategoryManufacturerPanel'
import Modal from '../common/Modal'
type Tab = 'categories' | 'manufacturers'
export default function CategoryLibraryModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<Tab>('categories')
return (
<Modal title="Categories & Manufacturers" onClose={onClose} width="max-w-xl">
<div className="mb-3 flex border-b border-slate-200">
<button
onClick={() => setTab('categories')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'categories' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Device Categories
</button>
<button
onClick={() => setTab('manufacturers')}
className={`px-3 py-1.5 text-xs font-semibold uppercase tracking-wide ${
tab === 'manufacturers' ? 'border-b-2 border-indigo-600 text-indigo-700' : 'text-slate-400 hover:text-slate-600'
}`}
>
Manufacturers
</button>
</div>
{tab === 'categories' ? (
<CategoryManufacturerPanel entityType="device_category" />
) : (
<CategoryManufacturerPanel entityType="manufacturer" />
)}
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,374 @@
import { useMemo, useState } from 'react'
import { deviceCategoryToRow, manufacturerToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import { allDeviceCategories, allManufacturers } from '../../domain/diagram'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
interface NamedEntity {
id: string
name: string
custom?: boolean
}
/** Inline rename/create row — every action here (add, rename, suggest an
* edit) is just "type a name, confirm", so one small form covers all of
* them rather than a bespoke component per action the way port/cable
* types need (PortTypeForm/CableTypeForm) for their much richer shapes. */
function NameForm({
initialName = '',
confirmLabel,
onCancel,
onSave,
}: {
initialName?: string
confirmLabel: string
onCancel: () => void
onSave: (name: string) => void
}) {
const [name, setName] = useState(initialName)
const commit = () => {
if (name.trim()) onSave(name.trim())
}
return (
<div className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commit()
} else if (e.key === 'Escape') {
onCancel()
}
}}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<button onClick={onCancel} className="shrink-0 text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={commit}
disabled={!name.trim()}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{confirmLabel}
</button>
</div>
)
}
type EditorTarget =
| { mode: 'new' }
| { mode: 'rename' | 'suggestEdit' | 'adminEdit'; entity: NamedEntity }
/**
* Device categories and manufacturers share an identical shape (just a
* name, plus the usual is_public/owner_id split) and an identical set of
* actions — one generic panel, parameterized by entity type, rather than
* duplicating PortTypesPanel/CableTypesPanel's much larger structure twice
* for something this simple.
*/
export default function CategoryManufacturerPanel({
entityType,
}: {
entityType: Extract<CatalogEntityType, 'device_category' | 'manufacturer'>
}) {
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const updateCustomDeviceCategory = useCatalogStore((s) => s.updateCustomDeviceCategory)
const removeCustomDeviceCategory = useCatalogStore((s) => s.removeCustomDeviceCategory)
const adminUpdateDeviceCategory = useCatalogStore((s) => s.adminUpdateDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const adminRemoveDeviceCategory = useCatalogStore((s) => s.adminRemoveDeviceCategory)
const addCustomManufacturer = useCatalogStore((s) => s.addCustomManufacturer)
const updateCustomManufacturer = useCatalogStore((s) => s.updateCustomManufacturer)
const removeCustomManufacturer = useCatalogStore((s) => s.removeCustomManufacturer)
const adminUpdateManufacturer = useCatalogStore((s) => s.adminUpdateManufacturer)
const adminAddManufacturer = useCatalogStore((s) => s.adminAddManufacturer)
const adminRemoveManufacturer = useCatalogStore((s) => s.adminRemoveManufacturer)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
const [publishNewPublic, setPublishNewPublic] = useState(false)
const isCategory = entityType === 'device_category'
const label = isCategory ? 'category' : 'manufacturer'
const items: NamedEntity[] = isCategory ? allDeviceCategories(catalog) : allManufacturers(catalog)
const addCustom = isCategory ? addCustomDeviceCategory : addCustomManufacturer
const updateCustom = isCategory ? updateCustomDeviceCategory : updateCustomManufacturer
const removeCustom = isCategory ? removeCustomDeviceCategory : removeCustomManufacturer
const adminUpdate = isCategory ? adminUpdateDeviceCategory : adminUpdateManufacturer
const adminAdd = isCategory ? adminAddDeviceCategory : adminAddManufacturer
const adminRemove = isCategory ? adminRemoveDeviceCategory : adminRemoveManufacturer
const toRow = isCategory ? deviceCategoryToRow : manufacturerToRow
const sortedItems = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items])
const customItems = useMemo(() => sortedItems.filter((i) => i.custom), [sortedItems])
const builtInItems = useMemo(() => sortedItems.filter((i) => !i.custom), [sortedItems])
const pendingEntityIds = useMemo(
() => new Set(mySubmissions.filter((s) => s.entityType === entityType && s.status === 'pending').map((s) => s.entityId)),
[mySubmissions, entityType],
)
const handleUnpublish = async (item: NamedEntity) => {
const impact = await getUsageImpact(entityType, item.id)
const impactNote =
impact.diagramCount > 0
? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.`
: '\n\nNot currently used in any diagram.'
const confirmed = window.confirm(`Unpublish "${item.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish(entityType, item.id)
}
// Deleting your own private category/manufacturer needs two checks
// first: a device_templates.category_id/manufacturer_id foreign key
// would otherwise reject the delete outright if any device template
// still references it (checked client-side here for a friendly message
// instead of a raw DB error) — and unlike that FK, nothing stops deleting
// one still referenced by a *placed device* inside a diagram (diagrams
// are a JSONB blob, not relationally linked), so that's checked via the
// same usage-impact function Admin unpublish uses, now extended to let
// you check your own still-private entries too. Either kind of usage
// blocks the delete outright — unlike unpublish, a hard delete can't be
// undone, so "confirm anyway" isn't offered here.
const handleDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by one of your devices in the palette. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${item.name}"? This can't be undone.`)
if (!confirmed) return
try {
await removeCustom(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be in use somewhere.`)
}
}
// Admin permanent-delete of a built-in entry — same zero-usage gate as
// handleDelete above (nothing stops here that unpublish wouldn't also
// have blocked with a confirm; this just doesn't offer "confirm anyway"
// since a hard delete can't be undone the way unpublish can).
const handleAdminDelete = async (item: NamedEntity) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => (isCategory ? t.category : t.manufacturerId) === item.id)
if (usedByTemplate) {
window.alert(`Can't delete "${item.name}" — it's still used by a device template. Change that device's ${label} first.`)
return
}
const impact = await getUsageImpact(entityType, item.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${item.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Permanently delete "${item.name}" from the shared library? This can't be undone.`)
if (!confirmed) return
try {
await adminRemove(item.id)
} catch {
window.alert(`Couldn't delete "${item.name}" — it may still be referenced somewhere.`)
}
}
return (
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customItems.length})
</h4>
{editorTarget?.mode !== 'new' && (
<button
onClick={() => setEditorTarget({ mode: 'new' })}
className="text-[11px] font-medium text-indigo-600 hover:underline"
>
+ New {label}
</button>
)}
</div>
{editorTarget?.mode === 'new' && (
<div className="mb-1.5 space-y-1.5">
{isAdmin && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input
type="checkbox"
checked={publishNewPublic}
onChange={(e) => setPublishNewPublic(e.target.checked)}
/>
Publish directly to the shared library (skips the review queue)
</label>
)}
<NameForm
confirmLabel="Add"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
if (isAdmin && publishNewPublic) adminAdd(name)
else addCustom(name)
setEditorTarget(null)
setPublishNewPublic(false)
}}
/>
</div>
)}
{customItems.length === 0 && editorTarget?.mode !== 'new' ? (
<p className="text-[11px] italic text-slate-400">None yet.</p>
) : (
<div className="space-y-1.5">
{customItems.map((item) =>
editorTarget?.mode === 'rename' && editorTarget.entity.id === item.id ? (
<NameForm
key={item.id}
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
updateCustom(item.id, name)
setEditorTarget(null)
}}
/>
) : (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs"
>
<span className="min-w-0 truncate font-medium text-slate-700">{item.name}</span>
<div className="flex shrink-0 items-center gap-1">
{pendingEntityIds.has(item.id) ? (
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
Pending review
</span>
) : (
<button
onClick={() => submitForReview(entityType, item.id, toRow(item.name))}
title={`Submit this ${label} to the public catalog for Admin review`}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
>
Submit
</button>
)}
<button
onClick={() => setEditorTarget({ mode: 'rename', entity: item })}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Rename
</button>
<button
onClick={() => handleDelete(item)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div>
),
)}
</div>
)}
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInItems.length})
</h4>
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInItems.map((item) => {
if (isAdmin && editorTarget?.mode === 'adminEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Save"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
adminUpdate(item.id, name)
setEditorTarget(null)
}}
/>
</div>
)
}
if (!isAdmin && editorTarget?.mode === 'suggestEdit' && editorTarget.entity.id === item.id) {
return (
<div key={item.id} className="mb-1.5">
<NameForm
initialName={item.name}
confirmLabel="Submit"
onCancel={() => setEditorTarget(null)}
onSave={(name) => {
submitForReview(entityType, item.id, toRow(name))
setEditorTarget(null)
}}
/>
</div>
)
}
return (
<div key={item.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
<span className="min-w-0 truncate">{item.name}</span>
{isAdmin ? (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setEditorTarget({ mode: 'adminEdit', entity: item })}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
<button
onClick={() => handleUnpublish(item)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Unpublish
</button>
<button
onClick={() => handleAdminDelete(item)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(item.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
Edit pending review
</span>
) : (
<button
onClick={() => setEditorTarget({ mode: 'suggestEdit', entity: item })}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Suggest edit
</button>
)}
</div>
)
})}
</div>
<p className="mt-1 text-[10px] text-slate-400">
{isAdmin
? `As an Admin, you can edit or unpublish public ${label}s directly.`
: `Built-in ${label}s can't be edited directly — "Suggest edit" sends a proposed change to Admins for review.`}
</p>
</div>
</div>
)
}
+436 -107
View File
@@ -1,72 +1,183 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { allDeviceCategories, allDeviceTemplates, hiddenBuiltInDeviceTemplates } from '../../domain/project' import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { allDeviceTemplates, deviceCategoryName, hiddenDeviceTemplates, manufacturerName } from '../../domain/diagram'
import type { DeviceTemplate } from '../../domain/types' import type { DeviceTemplate } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
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 { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import Chevron from '../common/Chevron'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
import CategoryLibraryModal from './CategoryLibraryModal'
import ConnectorLibraryModal from './ConnectorLibraryModal' import ConnectorLibraryModal from './ConnectorLibraryModal'
import DeviceTemplateEditor from './DeviceTemplateEditor' import DeviceTemplateEditor from './DeviceTemplateEditor'
export default function DevicePalette() { type EditorTarget =
const project = useProjectStore((s) => s.project) | { mode: 'new' }
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate) | { mode: 'edit'; template: DeviceTemplate }
const removeCustomDeviceTemplate = useProjectStore((s) => s.removeCustomDeviceTemplate) | { mode: 'suggestEdit'; template: DeviceTemplate }
const hideBuiltInDeviceTemplate = useProjectStore((s) => s.hideBuiltInDeviceTemplate) | { mode: 'adminEdit'; template: DeviceTemplate }
const restoreBuiltInDeviceTemplate = useProjectStore((s) => s.restoreBuiltInDeviceTemplate)
const [editorTarget, setEditorTarget] = useState<'new' | DeviceTemplate | null>(null)
const [portTypesOpen, setPortTypesOpen] = useState(false)
const [hiddenListOpen, setHiddenListOpen] = useState(false)
// Collapsed by default — expanding on demand is the whole point (keeps a
// library with many devices from turning into one long scroll).
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const groups = useMemo(() => { /** organized-ideas.md §5: browsable both category→manufacturer and
const categories = allDeviceCategories(project) * manufacturer→category, not a single fixed hierarchy — the two view
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id * modes swap which dimension is the top-level (collapsible) group and
const byCategory = new Map<string, DeviceTemplate[]>() * which is a plain sub-heading within it. */
for (const template of allDeviceTemplates(project)) { type ViewMode = 'category' | 'manufacturer'
const list = byCategory.get(template.category) ?? []
list.push(template) /** Sentinel sub/top-group key for devices with no manufacturer set —
byCategory.set(template.category, list) * distinct from any real id (manufacturer ids are uuids, this isn't), and
* always sorted last within its level since it's a fallback bucket, not a
* named group someone would look for alphabetically. */
const NO_MANUFACTURER_KEY = '__no-manufacturer__'
interface SubGroup {
key: string
label: string
templates: DeviceTemplate[]
} }
return [...byCategory.entries()]
.map(([categoryId, templates]) => ({
categoryId,
categoryName: categoryName(categoryId),
templates: templates.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
}, [project])
const toggleCategory = (categoryId: string) => { interface TopGroup {
setExpandedCategories((prev) => { key: string
label: string
subGroups: SubGroup[]
count: number
}
function sortGroupsByLabel<T extends { key: string; label: string }>(groups: T[]): T[] {
return groups.sort((a, b) => {
if (a.key === NO_MANUFACTURER_KEY) return 1
if (b.key === NO_MANUFACTURER_KEY) return -1
return a.label.localeCompare(b.label)
})
}
export default function DevicePalette() {
const diagram = useDiagramStore((s) => s.diagram)
const canEdit = useCanEdit()
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const catalog = useCatalogStore((s) => s.catalog)
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate)
const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate)
const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const adminRemoveDeviceTemplate = useCatalogStore((s) => s.adminRemoveDeviceTemplate)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const [editorTarget, setEditorTarget] = useState<EditorTarget | null>(null)
const [portTypesOpen, setPortTypesOpen] = useState(false)
const [categoryLibraryOpen, setCategoryLibraryOpen] = useState(false)
const [hiddenListOpen, setHiddenListOpen] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('category')
const [search, setSearch] = useState('')
// Collapsed by default — expanding on demand is the whole point (keeps a
// library with many devices from turning into one long scroll). Shared
// across both view modes rather than tracked per-mode: category ids and
// manufacturer ids live in different id spaces, so a stale entry left
// over from the other mode just never matches anything, harmlessly.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const groups = useMemo<TopGroup[]>(() => {
const templates = allDeviceTemplates(catalog, hiddenPublicIds)
const categoryLabel = (id: string) => deviceCategoryName(catalog, id)
const manufacturerLabel = (id: string) =>
id === NO_MANUFACTURER_KEY ? 'No manufacturer' : (manufacturerName(catalog, id) ?? id)
const manufacturerKey = (t: DeviceTemplate) => t.manufacturerId ?? NO_MANUFACTURER_KEY
const byTop = new Map<string, Map<string, DeviceTemplate[]>>()
for (const t of templates) {
const topKey = viewMode === 'category' ? t.category : manufacturerKey(t)
const subKey = viewMode === 'category' ? manufacturerKey(t) : t.category
const subMap = byTop.get(topKey) ?? new Map<string, DeviceTemplate[]>()
subMap.set(subKey, [...(subMap.get(subKey) ?? []), t])
byTop.set(topKey, subMap)
}
const topGroups = [...byTop.entries()].map(([topKey, subMap]) => {
const subGroups = sortGroupsByLabel(
[...subMap.entries()].map(([subKey, list]) => ({
key: subKey,
label: viewMode === 'category' ? manufacturerLabel(subKey) : categoryLabel(subKey),
templates: list.sort((a, b) => a.name.localeCompare(b.name)),
})),
)
return {
key: topKey,
label: viewMode === 'category' ? categoryLabel(topKey) : manufacturerLabel(topKey),
subGroups,
count: subGroups.reduce((sum, sg) => sum + sg.templates.length, 0),
}
})
return sortGroupsByLabel(topGroups)
}, [catalog, hiddenPublicIds, viewMode])
const searchResults = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return null
return allDeviceTemplates(catalog, hiddenPublicIds)
.filter((t) => {
const haystack = [t.name, t.model, manufacturerName(catalog, t.manufacturerId), deviceCategoryName(catalog, t.category)]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => a.name.localeCompare(b.name))
}, [catalog, hiddenPublicIds, search])
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(categoryId)) next.delete(categoryId) if (next.has(key)) next.delete(key)
else next.add(categoryId) else next.add(key)
return next return next
}) })
} }
const expandCategory = (categoryId: string) => { const expandGroup = (key: string) => {
setExpandedCategories((prev) => new Set(prev).add(categoryId)) setExpandedGroups((prev) => new Set(prev).add(key))
} }
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => { const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
if (!canEdit) return
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id) event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
} }
const handleQuickAdd = (template: DeviceTemplate) => { const handleQuickAdd = (template: DeviceTemplate) => {
// Cascade placement so repeated quick-adds don't stack exactly on top of each other. if (!canEdit) return
const offset = (project.devices.length % 8) * 24 // Tiled placement so repeated quick-adds land visibly apart rather than
addDeviceFromTemplate(template.id, { x: 80 + offset, y: 80 + offset }) // 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) => { const handleDelete = (template: DeviceTemplate) => {
if (template.builtIn) { if (!template.custom) {
const confirmed = window.confirm( const confirmed = window.confirm(
`Hide "${template.name}" from your device library? It's a built-in default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`, `Hide "${template.name}" from your device library? It's a public default, so you can bring it back later from "Show hidden defaults" at the bottom of this panel. Devices already placed on the canvas from it won't be affected.`,
) )
if (confirmed) hideBuiltInDeviceTemplate(template.id) if (confirmed) hidePublicDeviceTemplate(template.id)
return return
} }
const confirmed = window.confirm( const confirmed = window.confirm(
@@ -75,97 +186,166 @@ export default function DevicePalette() {
if (confirmed) removeCustomDeviceTemplate(template.id) if (confirmed) removeCustomDeviceTemplate(template.id)
} }
const hiddenDefaults = useMemo(() => hiddenBuiltInDeviceTemplates(project), [project]) const hiddenDefaults = useMemo(() => hiddenDeviceTemplates(catalog, hiddenPublicIds), [catalog, hiddenPublicIds])
const pendingEntityIds = useMemo(
() =>
new Set(
mySubmissions.filter((s) => s.entityType === 'device_template' && s.status === 'pending').map((s) => s.entityId),
),
[mySubmissions],
)
const handleSubmitForPublic = (template: DeviceTemplate) => {
submitForReview('device_template', template.id, deviceTemplateToRow(template))
}
const handleUnpublish = async (template: DeviceTemplate) => {
const impact = await getUsageImpact('device_template', template.id)
const impactNote =
impact.diagramCount > 0
? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.`
: '\n\nNot currently used in any diagram.'
const confirmed = window.confirm(`Unpublish "${template.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish('device_template', template.id)
}
// Distinct from the ✕ button below, which for a built-in device only ever
// hides it from *your own* view (a per-user preference, not a catalog
// change) — this permanently removes it from the shared library, gated by
// zero usage the same way a private device's delete already is. Device
// templates aren't referenced by any foreign key, so the diagram-usage
// check is the only thing to verify.
const handleAdminDelete = async (template: DeviceTemplate) => {
const impact = await getUsageImpact('device_template', template.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${template.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Permanently delete "${template.name}" from the shared library? This can't be undone.`)
if (!confirmed) return
try {
await adminRemoveDeviceTemplate(template.id)
} catch {
window.alert(`Couldn't delete "${template.name}" — it may still be referenced somewhere.`)
}
}
const rowProps = {
canEdit,
isAdmin,
pendingEntityIds,
onDragStart: handleDragStart,
onQuickAdd: handleQuickAdd,
onDelete: handleDelete,
onSubmitForPublic: handleSubmitForPublic,
onUnpublish: handleUnpublish,
onAdminDelete: handleAdminDelete,
onSuggestEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'suggestEdit', template }),
onAdminEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'adminEdit', template }),
onEdit: (template: DeviceTemplate) => setEditorTarget({ mode: 'edit', template }),
}
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">
<DropdownMenu label="Library">
<DropdownMenuItem onClick={() => setPortTypesOpen(true)}>Connectors</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCategoryLibraryOpen(true)}>Categories &amp; Manufacturers</DropdownMenuItem>
</DropdownMenu>
<button <button
onClick={() => setPortTypesOpen(true)} onClick={() => setEditorTarget({ mode: 'new' })}
title="Manage connector types"
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-200"
>
Connectors
</button>
<button
onClick={() => setEditorTarget('new')}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500" className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
> >
+ Custom + Custom
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-1.5 border-b border-slate-200 px-2 py-1.5">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search devices…"
className="w-full rounded border border-slate-300 px-2 py-1 text-xs"
/>
{!search && (
<div className="flex rounded bg-slate-100 p-0.5 text-[11px] font-medium">
<button
onClick={() => setViewMode('category')}
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'category' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
>
By Category
</button>
<button
onClick={() => setViewMode('manufacturer')}
className={`flex-1 rounded px-1.5 py-0.5 ${viewMode === 'manufacturer' ? 'bg-white text-slate-700 shadow-sm' : 'text-slate-500'}`}
>
By Manufacturer
</button>
</div>
)}
</div>
{!search && (
<p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500"> <p className="border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500">
Drag a device onto the canvas, or click + to drop it in. Drag a device onto the canvas, or click + to drop it in.
</p> </p>
)}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{groups.map((group) => { {searchResults ? (
const expanded = expandedCategories.has(group.categoryId) searchResults.length === 0 ? (
<p className="px-1.5 py-2 text-[11px] italic text-slate-400">No devices match "{search.trim()}".</p>
) : (
<ul className="space-y-1">
{searchResults.map((template) => (
<DeviceRow key={template.id} template={template} showContext {...rowProps} />
))}
</ul>
)
) : (
groups.map((group) => {
const expanded = expandedGroups.has(group.key)
return ( return (
<div key={group.categoryId} className="mb-1"> <div key={group.key} className="mb-1">
<button <button
onClick={() => toggleCategory(group.categoryId)} onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100" className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-100"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className={`inline-block text-[9px] text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`}> <Chevron expanded={expanded} className="text-slate-400" />
▶ {group.label}
</span> </span>
{group.categoryName} <span className="text-[10px] text-slate-400">{group.count}</span>
</span>
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
</button> </button>
{expanded && ( {expanded && (
<ul className="mt-1 space-y-1 pl-2"> <div className="mt-1 space-y-2 pl-2">
{group.templates.map((template) => ( {group.subGroups.map((subGroup) => (
<li key={template.id} className="group"> <div key={subGroup.key}>
<div {/* Not independently collapsible, unlike the top-level
draggable group — expanding the category/manufacturer above is
onDragStart={(e) => handleDragStart(e, template)} the one click this menu asks for; a second click per
className="flex cursor-grab items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm active:cursor-grabbing" sub-group would be one too many for groups that
> usually hold just a couple of devices. */}
<div className="min-w-0"> <div className="px-1.5 text-[10px] font-medium uppercase tracking-wide text-slate-400">
<div className="truncate font-medium text-slate-700">{template.name}</div> {subGroup.label}
<div className="truncate text-[10px] text-slate-400">
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
{!template.builtIn ? ' · custom' : ''}
</div> </div>
</div> <ul className="mt-0.5 space-y-1">
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100"> {subGroup.templates.map((template) => (
{!template.builtIn && ( <DeviceRow key={template.id} template={template} {...rowProps} />
<button
onClick={() => setEditorTarget(template)}
title="Edit this device"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
<button
onClick={() => handleDelete(template)}
title={template.builtIn ? 'Hide this default device' : 'Delete from library'}
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
✕
</button>
<button
onClick={() => handleQuickAdd(template)}
title="Add to canvas"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
+
</button>
</div>
</div>
</li>
))} ))}
</ul> </ul>
</div>
))}
</div>
)} )}
</div> </div>
) )
})} })
)}
</div> </div>
{hiddenDefaults.length > 0 && ( {hiddenDefaults.length > 0 && (
<div className="border-t border-slate-200 px-2 py-1.5"> <div className="border-t border-slate-200 px-2 py-1.5">
@@ -176,7 +356,7 @@ export default function DevicePalette() {
<span> <span>
{hiddenListOpen ? 'Hide' : 'Show'} hidden defaults ({hiddenDefaults.length}) {hiddenListOpen ? 'Hide' : 'Show'} hidden defaults ({hiddenDefaults.length})
</span> </span>
<span className={`inline-block text-[9px] transition-transform ${hiddenListOpen ? 'rotate-90' : ''}`}>▶</span> <Chevron expanded={hiddenListOpen} className="text-slate-500" />
</button> </button>
{hiddenListOpen && ( {hiddenListOpen && (
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
@@ -184,7 +364,7 @@ export default function DevicePalette() {
<li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs"> <li key={template.id} className="flex items-center justify-between rounded bg-white px-2 py-1 text-xs">
<span className="truncate text-slate-600">{template.name}</span> <span className="truncate text-slate-600">{template.name}</span>
<button <button
onClick={() => restoreBuiltInDeviceTemplate(template.id)} onClick={() => restorePublicDeviceTemplate(template.id)}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700" className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
> >
Restore Restore
@@ -197,12 +377,161 @@ export default function DevicePalette() {
)} )}
{editorTarget && ( {editorTarget && (
<DeviceTemplateEditor <DeviceTemplateEditor
template={editorTarget === 'new' ? undefined : editorTarget} template={editorTarget.mode === 'new' ? undefined : editorTarget.template}
submissionMode={editorTarget.mode === 'suggestEdit'}
adminMode={editorTarget.mode === 'adminEdit'}
onClose={() => setEditorTarget(null)} onClose={() => setEditorTarget(null)}
onSaved={(categoryId) => expandCategory(categoryId)} onSaved={(categoryId, manufacturerId) =>
expandGroup(viewMode === 'category' ? categoryId : (manufacturerId ?? NO_MANUFACTURER_KEY))
}
/> />
)} )}
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />} {portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
{categoryLibraryOpen && <CategoryLibraryModal onClose={() => setCategoryLibraryOpen(false)} />}
</aside> </aside>
) )
} }
/** One draggable device row, with whatever action buttons the current user
* and this template's public/custom/pending status allow — shared by the
* grouped tree and the flat search-results list so the two don't drift. */
function DeviceRow({
template,
canEdit,
isAdmin,
pendingEntityIds,
showContext,
onDragStart,
onQuickAdd,
onDelete,
onSubmitForPublic,
onUnpublish,
onAdminDelete,
onSuggestEdit,
onAdminEdit,
onEdit,
}: {
template: DeviceTemplate
canEdit: boolean
isAdmin: boolean
pendingEntityIds: Set<string>
/** Set in search results, where there's no grouping header to already
* convey which category/manufacturer this device belongs to. */
showContext?: boolean
onDragStart: (event: React.DragEvent, template: DeviceTemplate) => void
onQuickAdd: (template: DeviceTemplate) => void
onDelete: (template: DeviceTemplate) => void
onSubmitForPublic: (template: DeviceTemplate) => void
onUnpublish: (template: DeviceTemplate) => void
onAdminDelete: (template: DeviceTemplate) => void
onSuggestEdit: (template: DeviceTemplate) => void
onAdminEdit: (template: DeviceTemplate) => void
onEdit: (template: DeviceTemplate) => void
}) {
const catalog = useCatalogStore((s) => s.catalog)
const context = showContext
? [deviceCategoryName(catalog, template.category), manufacturerName(catalog, template.manufacturerId)]
.filter(Boolean)
.join(' · ')
: undefined
return (
<li className="group">
<div
draggable={canEdit}
onDragStart={(e) => onDragStart(e, template)}
className={`flex flex-wrap items-center gap-y-1 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{template.name}</div>
<div className="truncate text-[10px] text-slate-400">
{context && <>{context} · </>}
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
{template.custom ? ' · custom' : ''}
</div>
</div>
{/* `hidden`, not `opacity-0`, so the row's idle width isn't reserved
for buttons nobody's hovering to see — that reserved space was
squeezing long names down to almost nothing even at rest. Once
shown, `flex-wrap` on the row lets a crowded set (e.g. an Admin's
5 buttons) drop to its own line under the name instead of
squeezing it further. */}
<div className="ml-auto hidden shrink-0 items-center gap-1 group-hover:flex">
{pendingEntityIds.has(template.id) ? (
<span
title="A submission for this device is pending Admin review"
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
>
Pending
</span>
) : template.custom ? (
<button
onClick={() => onSubmitForPublic(template)}
title="Submit this to the public catalog for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-emerald-100 hover:text-emerald-700"
>
⇪
</button>
) : isAdmin ? (
<>
<button
onClick={() => onAdminEdit(template)}
title="Edit this public device directly"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
<button
onClick={() => onUnpublish(template)}
title="Unpublish from the public catalog"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
⛔
</button>
<button
onClick={() => onAdminDelete(template)}
title="Permanently delete from the shared library — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
🗑
</button>
</>
) : (
<button
onClick={() => onSuggestEdit(template)}
title="Suggest an edit to this device for Admin review"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
{template.custom && (
<button
onClick={() => onEdit(template)}
title="Edit this device"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
✎
</button>
)}
<button
onClick={() => onDelete(template)}
title={template.custom ? 'Delete from library' : 'Hide this default device'}
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
✕
</button>
{canEdit && (
<button
onClick={() => onQuickAdd(template)}
title="Add to canvas"
className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
+
</button>
)}
</div>
</div>
</li>
)
}
+166 -39
View File
@@ -1,9 +1,14 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { deviceTemplateToRow } from '../../data/catalogRowMapping'
import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants' import { PORT_CATEGORIES, PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/project' import { allPortTypes, expandCompatibleFamilyIds, incrementPortName } from '../../domain/diagram'
import type { DeviceTemplate, PortDirection } from '../../domain/types' import type { DeviceTemplate, PortDirection } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore' import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
import CategorySelect from '../common/CategorySelect' import CategorySelect from '../common/CategorySelect'
import FamilySelect from '../common/FamilySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
import Modal from '../common/Modal' import Modal from '../common/Modal'
interface DraftPort { interface DraftPort {
@@ -11,6 +16,7 @@ interface DraftPort {
name: string name: string
direction: PortDirection direction: PortDirection
portTypeId: string portTypeId: string
builtInCable?: boolean
} }
let draftKeySeq = 0 let draftKeySeq = 0
@@ -21,33 +27,69 @@ function nextDraftKey() {
export default function DeviceTemplateEditor({ export default function DeviceTemplateEditor({
template, template,
submissionMode = false,
adminMode = false,
resubmitId,
onClose, onClose,
onSaved, onSaved,
}: { }: {
/** When set, edits this existing custom template instead of creating a new one. */ /** When set, edits/prefills from this existing template instead of creating a new one. */
template?: DeviceTemplate template?: DeviceTemplate
/** When true, `template` is a public entry you don't own — saving submits
* a suggested edit for Admin review instead of mutating it directly
* (which RLS wouldn't allow anyway). */
submissionMode?: boolean
/** When true, saves directly to the public entry (organized-ideas.md §6:
* Admin/Super-Admin can CRUD public entries without going through the
* submission workflow). Takes precedence over `submissionMode`. */
adminMode?: boolean
/** When set, saving revises this existing submission (of your own,
* pending or rejected) instead of creating a new one — see
* MySubmissionsModal's "Edit & resubmit". Takes precedence over
* `submissionMode`/`adminMode` if somehow more than one is set. */
resubmitId?: string
onClose: () => void onClose: () => void
/** Called with the template's category id right before closing, so the /** Called with the template's category and manufacturer ids right before
* palette can expand that category and reveal what was just created/edited. */ * closing, so the palette can expand whichever group (category or
onSaved?: (categoryId: string) => void * manufacturer, depending on its current view mode) contains what was
* just created/edited. */
onSaved?: (categoryId: string, manufacturerId: string | undefined) => void
}) { }) {
const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceTemplate = useProjectStore((s) => s.addCustomDeviceTemplate) const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useProjectStore((s) => s.updateCustomDeviceTemplate) const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
const addCustomPortType = useProjectStore((s) => s.addCustomPortType) const adminUpdateDeviceTemplate = useCatalogStore((s) => s.adminUpdateDeviceTemplate)
const adminAddDeviceTemplate = useCatalogStore((s) => s.adminAddDeviceTemplate)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const resubmit = useSubmissionStore((s) => s.resubmit)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
// Only meaningful when creating a genuinely new device (not editing,
// suggesting, resubmitting, or admin-editing an existing public one) —
// those all already have their own place in the ownership/review model.
const canPublishDirectly = isAdmin && !template && !submissionMode && !adminMode && !resubmitId
const portTypes = useMemo( const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
const [name, setName] = useState(template?.name ?? '') const [name, setName] = useState(template?.name ?? '')
const [category, setCategory] = useState<string>(template?.category ?? 'other') const [category, setCategory] = useState<string>(template?.category ?? 'other')
const [manufacturer, setManufacturer] = useState(template?.manufacturer ?? '') const [manufacturerId, setManufacturerId] = useState<string | undefined>(template?.manufacturerId)
const [model, setModel] = useState(template?.model ?? '') const [model, setModel] = useState(template?.model ?? '')
const [cost, setCost] = useState(template?.cost?.toString() ?? '')
const [ports, setPorts] = useState<DraftPort[]>( const [ports, setPorts] = useState<DraftPort[]>(
() => () =>
template?.ports.map((p) => ({ key: p.id, name: p.name, direction: p.direction, portTypeId: p.portTypeId })) ?? [ template?.ports.map((p) => ({
key: p.id,
name: p.name,
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})) ?? [
{ key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' }, { key: nextDraftKey(), name: 'Port 1', direction: 'output', portTypeId: portTypes[0]?.id ?? '' },
], ],
) )
@@ -57,6 +99,7 @@ export default function DeviceTemplateEditor({
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other') const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set()) const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
const [focusKey, setFocusKey] = useState<string | null>(null) const [focusKey, setFocusKey] = useState<string | null>(null)
const [publishDirectly, setPublishDirectly] = useState(false)
const toggleAlsoCompatible = (portTypeId: string) => { const toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => { setAlsoCompatibleWith((prev) => {
@@ -99,12 +142,17 @@ export default function DeviceTemplateEditor({
const handleCreatePortType = () => { const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes) const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({ const fields = {
name: newPortTypeName.trim(), name: newPortTypeName.trim(),
category: newPortTypeCategory, category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'), family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}), ...(compatibleFamilyIds.length > 0 ? { compatibleFamilyIds } : {}),
}) }
// If this device is going straight into the public catalog, a connector
// type invented for it needs to as well — otherwise the public device
// ends up referencing a private port type nobody else can see.
if (canPublishDirectly && publishDirectly) adminAddPortType(fields)
else addCustomPortType(fields)
setNewPortTypeName('') setNewPortTypeName('')
setNewPortTypeFamily('') setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set()) setAlsoCompatibleWith(new Set())
@@ -118,21 +166,50 @@ export default function DeviceTemplateEditor({
const fields = { const fields = {
name: name.trim(), name: name.trim(),
category, category,
manufacturer: manufacturer.trim() || undefined, manufacturerId,
model: model.trim() || undefined, model: model.trim() || undefined,
ports: ports.map((p) => ({ id: p.key, name: p.name.trim() || 'Port', direction: p.direction, portTypeId: p.portTypeId })), cost: cost.trim() === '' ? undefined : Number(cost),
ports: ports.map((p) => ({
id: p.key,
name: p.name.trim() || 'Port',
direction: p.direction,
portTypeId: p.portTypeId,
builtInCable: p.builtInCable,
})),
} }
if (template) { if (resubmitId) {
resubmit(resubmitId, deviceTemplateToRow(fields))
} else if (adminMode && template) {
adminUpdateDeviceTemplate(template.id, fields)
} else if (submissionMode && template) {
submitForReview('device_template', template.id, deviceTemplateToRow(fields))
} else if (template) {
updateCustomDeviceTemplate(template.id, fields) updateCustomDeviceTemplate(template.id, fields)
} else if (canPublishDirectly && publishDirectly) {
adminAddDeviceTemplate(fields)
} else { } else {
addCustomDeviceTemplate(fields) addCustomDeviceTemplate(fields)
} }
onSaved?.(category) onSaved?.(category, manufacturerId)
onClose() onClose()
} }
return ( return (
<Modal title={template ? 'Edit Custom Device' : 'New Custom Device'} onClose={onClose} width="max-w-xl"> <Modal
title={
resubmitId
? 'Revise Submission'
: adminMode
? 'Edit Public Device'
: submissionMode
? 'Suggest an Edit'
: template
? 'Edit Custom Device'
: 'New Custom Device'
}
onClose={onClose}
width="max-w-xl"
>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<label className="col-span-2 text-xs font-medium text-slate-600"> <label className="col-span-2 text-xs font-medium text-slate-600">
@@ -146,17 +223,17 @@ export default function DeviceTemplateEditor({
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Category Category
<CategorySelect value={category} onChange={setCategory} /> <CategorySelect value={category} onChange={setCategory} publishPublic={canPublishDirectly && publishDirectly} />
</label> </label>
<label className="text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Manufacturer Manufacturer
<input <ManufacturerSelect
value={manufacturer} value={manufacturerId}
onChange={(e) => setManufacturer(e.target.value)} onChange={setManufacturerId}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm" publishPublic={canPublishDirectly && publishDirectly}
/> />
</label> </label>
<label className="col-span-2 text-xs font-medium text-slate-600"> <label className="text-xs font-medium text-slate-600">
Model Model
<input <input
value={model} value={model}
@@ -164,13 +241,45 @@ export default function DeviceTemplateEditor({
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm" className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
/> />
</label> </label>
<label className="text-xs font-medium text-slate-600">
Suggested cost
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-slate-500">$</span>
<input
type="number"
min={0}
step={0.01}
value={cost}
onChange={(e) => setCost(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
placeholder="e.g. 249.00"
/>
</div> </div>
</label>
</div>
{canPublishDirectly && (
<label className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50/60 px-2 py-1.5 text-xs text-amber-800">
<input
type="checkbox"
checked={publishDirectly}
onChange={(e) => setPublishDirectly(e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-medium">Publish directly to the shared library.</span> Skips the review queue —
use this when you're authoring the official catalog yourself. Any new category, manufacturer, or
connector type you create below while this is checked is published too, so the device doesn't end up
referencing something private.
</span>
</label>
)}
<div> <div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4> <h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
<div className="space-y-1.5"> <div className="space-y-1.5">
{ports.map((port) => ( {ports.map((port) => (
<div key={port.key} className="flex items-center gap-1.5"> <div key={port.key} className="flex flex-wrap items-center gap-1.5">
<input <input
ref={(el) => { ref={(el) => {
if (el && focusKey === port.key) { if (el && focusKey === port.key) {
@@ -205,6 +314,20 @@ export default function DeviceTemplateEditor({
</option> </option>
))} ))}
</select> </select>
{/* Grouped so these three wrap to their own line as a unit
rather than each one reflowing separately if the row
ever gets tighter than it can fit. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => updatePort(port.key, { builtInCable: !port.builtInCable })}
tabIndex={-1}
title="Built-in cable"
className={`shrink-0 rounded px-1 ${
port.builtInCable ? 'bg-amber-100 opacity-100 ring-1 ring-amber-300' : 'opacity-30 hover:opacity-60'
}`}
>
🔌
</button>
<button <button
onClick={() => clonePortRow(port.key)} onClick={() => clonePortRow(port.key)}
tabIndex={-1} tabIndex={-1}
@@ -222,6 +345,7 @@ export default function DeviceTemplateEditor({
✕ ✕
</button> </button>
</div> </div>
</div>
))} ))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>} {ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div> </div>
@@ -258,18 +382,11 @@ export default function DeviceTemplateEditor({
))} ))}
</select> </select>
</div> </div>
<input <FamilySelect value={newPortTypeFamily} onChange={setNewPortTypeFamily} portTypes={portTypes} />
value={newPortTypeFamily} <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>
onChange={(e) => setNewPortTypeFamily(e.target.value)}
placeholder="Compatibility family (optional, defaults to name)"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<p className="text-[10px] text-slate-500">
Two ports can be wired together only if their connector type shares the same family.
</p>
<div> <div>
<p className="text-[10px] font-medium text-slate-600"> <p className="text-[10px] font-medium text-slate-600">
Also compatible with (for jacks that accept more than one kind — e.g. a patch bay that's Can accept connections from (for jacks that accept more than one kind — e.g. a patch bay that's
balanced or unbalanced): balanced or unbalanced):
</p> </p>
<div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5"> <div className="mt-1 max-h-24 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
@@ -310,7 +427,17 @@ export default function DeviceTemplateEditor({
disabled={!canSave} disabled={!canSave}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40" className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
> >
{template ? 'Save changes' : 'Save device'} {resubmitId
? 'Resubmit'
: adminMode
? 'Save changes'
: submissionMode
? 'Submit for review'
: template
? 'Save changes'
: canPublishDirectly && publishDirectly
? 'Publish device'
: 'Save device'}
</button> </button>
</div> </div>
</div> </div>
+259 -38
View File
@@ -1,26 +1,41 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { PORT_CATEGORIES } from '../../domain/constants' import { PORT_CATEGORIES } from '../../domain/constants'
import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/project' import { allPortTypes, expandCompatibleFamilyIds } from '../../domain/diagram'
import type { PortType } from '../../domain/types' import type { PortType } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore' import { portTypeToRow } from '../../data/catalogRowMapping'
import FamilySelect from '../common/FamilySelect'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
function EditCustomPortTypeForm({ export function PortTypeForm({
portType, portType,
allTypes, allTypes,
showPublishToggle = false,
onCancel, onCancel,
onSave,
}: { }: {
portType: PortType /** Omitted when defining a brand-new port type — mirrors CableTypeForm's
* optional `initial`. */
portType?: PortType
allTypes: PortType[] allTypes: PortType[]
/** Shows a "Publish directly" checkbox and passes its value as onSave's
* second argument — only meaningful for an Admin defining a brand-new
* type (never set for edit/suggest-edit/admin-edit). */
showPublishToggle?: boolean
onCancel: () => void onCancel: () => void
onSave: (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => void
}) { }) {
const updateCustomPortType = useProjectStore((s) => s.updateCustomPortType) const [name, setName] = useState(portType?.name ?? '')
const [name, setName] = useState(portType.name) const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType?.category ?? 'other')
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category) const [family, setFamily] = useState(portType?.family ?? '')
const [family, setFamily] = useState(portType.family)
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => { const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
if (!portType) return new Set()
const currentFamilies = new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])]) const currentFamilies = new Set([portType.family, ...(portType.compatibleFamilyIds ?? [])])
return new Set(allTypes.filter((pt) => pt.id !== portType.id && currentFamilies.has(pt.family)).map((pt) => pt.id)) return new Set(allTypes.filter((pt) => pt.id !== portType.id && currentFamilies.has(pt.family)).map((pt) => pt.id))
}) })
const [publishPublic, setPublishPublic] = useState(false)
const toggle = (id: string) => { const toggle = (id: string) => {
setAlsoCompatibleWith((prev) => { setAlsoCompatibleWith((prev) => {
@@ -31,18 +46,28 @@ function EditCustomPortTypeForm({
}) })
} }
const canSave = name.trim().length > 0
const handleSave = () => { const handleSave = () => {
if (!name.trim() || !family.trim()) return if (!canSave) return
// Mirrors DeviceTemplateEditor's inline connector-type panel: an empty
// family (never confirmed in the FamilySelect sub-form below) defaults
// to the connector's own name rather than silently blocking Save — a
// brand-new connector is its own family unless told otherwise.
const resolvedFamily = (family.trim() || name.trim()).toLowerCase().replace(/\s+/g, '-')
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter( const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], allTypes).filter(
(f) => f !== family.trim(), (f) => f !== resolvedFamily,
) )
updateCustomPortType(portType.id, { onSave(
{
name: name.trim(), name: name.trim(),
category, category,
family: family.trim(), family: resolvedFamily,
compatibleFamilyIds, compatibleFamilyIds,
}) maxConnections: portType?.maxConnections,
onCancel() },
publishPublic,
)
} }
return ( return (
@@ -65,17 +90,13 @@ function EditCustomPortTypeForm({
))} ))}
</select> </select>
</div> </div>
<input <FamilySelect value={family} onChange={setFamily} portTypes={allTypes} />
value={family} {!portType && <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>}
onChange={(e) => setFamily(e.target.value)}
placeholder="Compatibility family"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<div> <div>
<p className="text-[10px] font-medium text-slate-600">Also compatible with:</p> <p className="text-[10px] font-medium text-slate-600">Can accept connections from:</p>
<div className="mt-1 max-h-32 overflow-y-auto rounded border border-slate-200 bg-white p-1.5"> <div className="mt-1 max-h-32 overflow-y-auto rounded border border-slate-200 bg-white p-1.5">
{allTypes {allTypes
.filter((pt) => pt.id !== portType.id) .filter((pt) => pt.id !== portType?.id)
.map((pt) => ( .map((pt) => (
<label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600"> <label key={pt.id} className="flex items-center gap-1.5 py-0.5 text-[11px] text-slate-600">
<input type="checkbox" checked={alsoCompatibleWith.has(pt.id)} onChange={() => toggle(pt.id)} /> <input type="checkbox" checked={alsoCompatibleWith.has(pt.id)} onChange={() => toggle(pt.id)} />
@@ -84,13 +105,20 @@ function EditCustomPortTypeForm({
))} ))}
</div> </div>
</div> </div>
{showPublishToggle && (
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
<input type="checkbox" checked={publishPublic} onChange={(e) => setPublishPublic(e.target.checked)} />
Publish directly to the shared library (skips the review queue)
</label>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={onCancel} className="text-[11px] text-slate-500"> <button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel Cancel
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500" disabled={!canSave}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
> >
Save Save
</button> </button>
@@ -100,16 +128,42 @@ function EditCustomPortTypeForm({
} }
export default function PortTypesPanel() { export default function PortTypesPanel() {
const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog)
const addCustomPortType = useCatalogStore((s) => s.addCustomPortType)
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
const removeCustomPortType = useCatalogStore((s) => s.removeCustomPortType)
const adminUpdatePortType = useCatalogStore((s) => s.adminUpdatePortType)
const adminAddPortType = useCatalogStore((s) => s.adminAddPortType)
const adminRemovePortType = useCatalogStore((s) => s.adminRemovePortType)
const adminUnpublish = useCatalogStore((s) => s.adminUnpublish)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const submitForReview = useSubmissionStore((s) => s.submitForReview)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
const allTypes = useMemo( const allTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)), () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[project], [catalog],
) )
const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes]) const customTypes = useMemo(() => allTypes.filter((pt) => pt.custom), [allTypes])
const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes]) const builtInTypes = useMemo(() => allTypes.filter((pt) => !pt.custom), [allTypes])
// Entities with a submission of yours already pending — used to hide the
// submit/suggest actions so the same entity can't be piled onto the
// review queue twice while an earlier submission is still outstanding.
const pendingEntityIds = useMemo(
() =>
new Set(
mySubmissions.filter((s) => s.entityType === 'port_type' && s.status === 'pending').map((s) => s.entityId),
),
[mySubmissions],
)
const describeCompatibility = (pt: PortType) => { const describeCompatibility = (pt: PortType) => {
const families = new Set(pt.compatibleFamilyIds ?? []) const families = new Set(pt.compatibleFamilyIds ?? [])
if (families.size === 0) return null if (families.size === 0) return null
@@ -117,25 +171,107 @@ export default function PortTypesPanel() {
return names.length > 0 ? names.join(', ') : null return names.length > 0 ? names.join(', ') : null
} }
const handleCreate = (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => {
if (isAdmin && publishPublic) adminAddPortType(fields)
else addCustomPortType(fields)
setCreating(false)
}
const handleSubmitForPublic = (pt: PortType) => {
submitForReview('port_type', pt.id, portTypeToRow(pt))
}
const handleSuggestEdit = (id: string, fields: Omit<PortType, 'id' | 'custom'>) => {
submitForReview('port_type', id, portTypeToRow(fields))
setSuggestingId(null)
}
const handleAdminSave = (id: string, fields: Omit<PortType, 'id' | 'custom'>) => {
adminUpdatePortType(id, fields)
setAdminEditingId(null)
}
const handleUnpublish = async (pt: PortType) => {
const impact = await getUsageImpact('port_type', pt.id)
const impactNote =
impact.diagramCount > 0
? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.`
: '\n\nNot currently used in any diagram.'
const confirmed = window.confirm(`Unpublish "${pt.name}" from the public catalog?${impactNote}`)
if (!confirmed) return
adminUnpublish('port_type', pt.id)
}
// Shared by both the Custom (your own private type) and Built-in (Admin
// permanent-delete) delete flows — same two-stage check as
// CategoryManufacturerPanel's handleDelete: a device template's port
// still pointing at it would otherwise fail as a raw foreign-key
// violation, and nothing stops deleting one still used by a *placed*
// device in a diagram (diagrams are a JSONB blob, not relationally
// linked). Either kind of usage blocks the delete outright — unlike
// unpublish, a hard delete can't be undone.
const deleteIfUnused = async (pt: PortType, remove: (id: string) => Promise<void>) => {
const usedByTemplate = catalog.deviceTemplates.some((t) => t.ports.some((p) => p.portTypeId === pt.id))
if (usedByTemplate) {
window.alert(`Can't delete "${pt.name}" — it's still used by a device template's port.`)
return
}
const impact = await getUsageImpact('port_type', pt.id)
if (impact.diagramCount > 0) {
window.alert(
`Can't delete "${pt.name}" — used in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}.`,
)
return
}
const confirmed = window.confirm(`Delete "${pt.name}"? This can't be undone.`)
if (!confirmed) return
try {
await remove(pt.id)
} catch {
window.alert(`Couldn't delete "${pt.name}" — it may still be in use somewhere.`)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500"> <div className="mb-1 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Custom ({customTypes.length}) Custom ({customTypes.length})
</h4> </h4>
{customTypes.length === 0 ? ( {!creating && (
<button onClick={() => setCreating(true)} className="text-[11px] font-medium text-indigo-600 hover:underline">
+ Define a new port type
</button>
)}
</div>
{creating && (
<div className="mb-1.5">
<PortTypeForm
allTypes={allTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div>
)}
{customTypes.length === 0 && !creating ? (
<p className="text-[11px] italic text-slate-400"> <p className="text-[11px] italic text-slate-400">
None yet — define one from the "+ Define a new connector type" link when adding a custom device. None yet — useful for a connector this app doesn't already have, like a proprietary jack.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{customTypes.map((pt) => {customTypes.map((pt) =>
editingId === pt.id ? ( editingId === pt.id ? (
<EditCustomPortTypeForm <PortTypeForm
key={pt.id} key={pt.id}
portType={pt} portType={pt}
allTypes={allTypes} allTypes={allTypes}
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}
onSave={(fields) => {
updateCustomPortType(pt.id, fields)
setEditingId(null)
}}
/> />
) : ( ) : (
<div <div
@@ -146,15 +282,36 @@ export default function PortTypesPanel() {
<div className="font-medium text-slate-700">{pt.name}</div> <div className="font-medium text-slate-700">{pt.name}</div>
<div className="truncate text-[10px] text-slate-400"> <div className="truncate text-[10px] text-slate-400">
family: {pt.family} family: {pt.family}
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''} {describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1">
{pendingEntityIds.has(pt.id) ? (
<span className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-600">
Pending review
</span>
) : (
<button
onClick={() => handleSubmitForPublic(pt)}
title="Submit this to the public catalog for Admin review"
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-emerald-100 hover:text-emerald-700"
>
Submit
</button>
)}
<button <button
onClick={() => setEditingId(pt.id)} onClick={() => setEditingId(pt.id)}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700" className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
> >
Edit Edit
</button> </button>
<button
onClick={() => deleteIfUnused(pt, removeCustomPortType)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
</div> </div>
), ),
)} )}
@@ -166,17 +323,81 @@ export default function PortTypesPanel() {
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500"> <h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length}) Built-in ({builtInTypes.length})
</h4> </h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5"> <div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((pt) => ( {builtInTypes.map((pt) => {
<div key={pt.id} className="px-1 py-0.5 text-[11px] text-slate-500"> if (isAdmin && adminEditingId === pt.id) {
return (
<PortTypeForm
key={pt.id}
portType={pt}
allTypes={allTypes}
onCancel={() => setAdminEditingId(null)}
onSave={(fields) => handleAdminSave(pt.id, fields)}
/>
)
}
if (!isAdmin && suggestingId === pt.id) {
return (
<PortTypeForm
key={pt.id}
portType={pt}
allTypes={allTypes}
onCancel={() => setSuggestingId(null)}
onSave={(fields) => handleSuggestEdit(pt.id, fields)}
/>
)
}
return (
<div key={pt.id} className="flex items-center justify-between gap-2 px-1 py-0.5 text-[11px] text-slate-500">
<span className="min-w-0 truncate">
{pt.name} {pt.name}
{describeCompatibility(pt) && ( {describeCompatibility(pt) && (
<span className="text-slate-400"> · also fits: {describeCompatibility(pt)}</span> <span className="text-slate-400"> · can accept: {describeCompatibility(pt)}</span>
)}
</span>
{isAdmin ? (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setAdminEditingId(pt.id)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
<button
onClick={() => handleUnpublish(pt)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Unpublish
</button>
<button
onClick={() => deleteIfUnused(pt, adminRemovePortType)}
title="Permanently delete — only possible while nothing uses it"
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-red-50 hover:text-red-600"
>
Delete
</button>
</div>
) : pendingEntityIds.has(pt.id) ? (
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
Edit pending review
</span>
) : (
<button
onClick={() => setSuggestingId(pt.id)}
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 hover:bg-indigo-100 hover:text-indigo-700"
>
Suggest edit
</button>
)} )}
</div> </div>
))} )
})}
</div> </div>
<p className="mt-1 text-[10px] text-slate-400">Built-in connector types can't be edited or removed.</p> <p className="mt-1 text-[10px] text-slate-400">
{isAdmin
? 'As an Admin, you can edit or unpublish public connector types directly.'
: 'Built-in connector types can\'t be edited directly — "Suggest edit" sends a proposed change to Admins for review.'}
</p>
</div> </div>
</div> </div>
) )
@@ -0,0 +1,29 @@
import type { CableType, PortType } from '../../domain/types'
export interface CableTypeFormFields {
name: string
endAPortTypeId: string
endBPortTypeId: string
unit: CableType['unit']
costPerUnit?: number
}
/** Resolves the form's port-type-based picks into the family/family2 shape
* a CableType actually stores. Shared by every save path (create, direct
* edit, submit-for-public, suggest-edit) so they can't drift apart.
* Split out of CableTypeManager.tsx (a plain function alongside a
* component there defeats fast refresh) rather than folded into
* data/catalogRowMapping.ts since this is form-input resolution, not a
* domain-object-to-row mapping. */
export function resolveCableTypeFields(fields: CableTypeFormFields, portTypes: PortType[]): Omit<CableType, 'id' | 'custom'> | undefined {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return undefined
return {
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
costPerUnit: fields.costPerUnit,
}
}
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import type { CollaboratorPermission } from '../../data/DiagramCollaboratorRepository'
import { SelfCollaboratorError, UnknownUsernameError } from '../../data/DiagramCollaboratorRepository'
import { useDiagramCollaboratorStore } from '../../state/diagramCollaboratorStore'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
export default function DiagramSharingModal({ onClose }: { onClose: () => void }) {
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const isOwner = access?.myPermission === 'owner'
const diagramId = diagram.id
const collaborators = useDiagramCollaboratorStore((s) => s.collaborators)
const isLoaded = useDiagramCollaboratorStore((s) => s.isLoaded)
const load = useDiagramCollaboratorStore((s) => s.load)
const add = useDiagramCollaboratorStore((s) => s.add)
const updatePermission = useDiagramCollaboratorStore((s) => s.updatePermission)
const remove = useDiagramCollaboratorStore((s) => s.remove)
const [username, setUsername] = useState('')
const [permission, setPermission] = useState<CollaboratorPermission>('view')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
load(diagramId)
}, [diagramId, load])
const handleAdd = async () => {
const trimmed = username.trim()
if (!trimmed) return
setError(null)
setBusy(true)
try {
await add(diagramId, trimmed, permission)
setUsername('')
} catch (err) {
setError(
err instanceof UnknownUsernameError || err instanceof SelfCollaboratorError
? err.message
: 'Could not add that person. Try again.',
)
} finally {
setBusy(false)
}
}
const handleRemove = (userId: string, name: string) => {
const confirmed = window.confirm(`Remove "${name}" from this diagram? They'll immediately lose access.`)
if (confirmed) remove(diagramId, userId)
}
return (
<Modal title={`Share "${diagram.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-3">
{isOwner && (
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
<div className="flex gap-1.5">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder="Username"
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as CollaboratorPermission)}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button
onClick={handleAdd}
disabled={busy || !username.trim()}
className="shrink-0 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Add
</button>
</div>
{error && <p className="text-[11px] text-red-600">{error}</p>}
</div>
)}
<div className="space-y-1.5">
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
{isLoaded && collaborators.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">
Not shared with anyone yet{isOwner ? ' — add someone above.' : '.'}
</p>
)}
{collaborators.map((c) => (
<div key={c.userId} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
<span className="font-medium text-slate-700">{c.username}</span>
{isOwner ? (
<div className="flex shrink-0 items-center gap-1.5">
<select
value={c.permission}
onChange={(e) => updatePermission(diagramId, c.userId, e.target.value as CollaboratorPermission)}
className="rounded border border-slate-300 px-1 py-1 text-[11px]"
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button
onClick={() => handleRemove(c.userId, c.username)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Remove
</button>
</div>
) : (
<span className="shrink-0 text-[11px] text-slate-400">{c.permission === 'edit' ? 'Can edit' : 'Can view'}</span>
)}
</div>
))}
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { useDiagramSnapshotStore } from '../../state/diagramSnapshotStore'
import { useDiagramStore } from '../../state/diagramStore'
import Modal from '../common/Modal'
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
export default function VersionHistoryModal({ onClose }: { onClose: () => void }) {
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canRestore = access?.myPermission === 'owner' || access?.myPermission === 'edit'
const diagramId = diagram.id
const snapshots = useDiagramSnapshotStore((s) => s.snapshots)
const isLoaded = useDiagramSnapshotStore((s) => s.isLoaded)
const load = useDiagramSnapshotStore((s) => s.load)
const restore = useDiagramSnapshotStore((s) => s.restore)
const [restoringId, setRestoringId] = useState<string | null>(null)
useEffect(() => {
load(diagramId)
}, [diagramId, load])
const handleRestore = async (snapshotId: string, createdAt: string) => {
const confirmed = window.confirm(
`Restore the diagram to how it was on ${formatDateTime(createdAt)}? If you've made changes since your last restore, the current state is saved as a new checkpoint first, so this can be undone.`,
)
if (!confirmed) return
setRestoringId(snapshotId)
try {
await restore(diagramId, snapshotId)
} finally {
setRestoringId(null)
}
}
return (
<Modal title={`Version History — "${diagram.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-1.5">
<p className="text-[11px] text-slate-500">
Checkpoints saved periodically while editing (up to the 50 most recent).
</p>
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
{isLoaded && snapshots.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">No checkpoints yet.</p>
)}
{snapshots.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
<div className="min-w-0">
<div className="font-medium text-slate-700">{formatDateTime(s.createdAt)}</div>
{s.savedByUsername && <div className="text-[10px] text-slate-400">by {s.savedByUsername}</div>}
</div>
{canRestore && (
<button
onClick={() => handleRestore(s.id, s.createdAt)}
disabled={restoringId === s.id}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700 disabled:cursor-not-allowed disabled:opacity-40"
>
Restore
</button>
)}
</div>
))}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from 'react'
import type { UsageImpact } from '../../data/AdminSubmissionRepository'
import type { CatalogSubmission } from '../../data/SubmissionRepository'
import type { Catalog } from '../../domain/types'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useCatalogStore } from '../../state/catalogStore'
import Modal from '../common/Modal'
import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup'
import { findLikelyDuplicates } from './duplicateDetection'
import { diffRows, fieldLabel, formatDiffValue } from './submissionDiff'
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
device_template: 'Device',
port_type: 'Port type',
cable_type: 'Cable type',
device_category: 'Category',
manufacturer: 'Manufacturer',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function SubmissionReviewCard({
submission,
catalog,
submitterUsername,
}: {
submission: CatalogSubmission
catalog: Catalog
submitterUsername: string | undefined
}) {
const approve = useAdminReviewStore((s) => s.approve)
const reject = useAdminReviewStore((s) => s.reject)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const [rejecting, setRejecting] = useState(false)
const [reason, setReason] = useState('')
const [busy, setBusy] = useState(false)
const [impact, setImpact] = useState<UsageImpact | null>(null)
const [impactLoading, setImpactLoading] = useState(false)
const currentEntity = findCatalogEntity(catalog, submission.entityType, submission.entityId)
// Already public: this is a "suggest an edit" submission, so the impact
// check applies (organized-ideas.md §3) — an edit to an entry already in
// use elsewhere is the case that check exists for. A submission whose
// entity is still private is a "promote my own entry" submission — only
// the submitter's own diagrams could reference it, nothing to warn about.
const isEditToPublicEntry = currentEntity?.custom === false
useEffect(() => {
if (!isEditToPublicEntry) return
setImpactLoading(true)
getUsageImpact(submission.entityType, submission.entityId)
.then(setImpact)
.finally(() => setImpactLoading(false))
// Only ever needs to run once per card mount — the entity/submission
// this card is about doesn't change out from under it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditToPublicEntry])
const diffs = useMemo(() => {
const currentRow = currentEntity ? catalogEntityToRow(submission.entityType, currentEntity) : {}
return diffRows(currentRow, submission.proposedData)
}, [currentEntity, submission.entityType, submission.proposedData])
// Device names, categories, and manufacturers all benefit from the same
// nudge — a normalized manufacturer catalog only actually stays clean if
// near-duplicate manufacturer/category submissions get flagged the same
// way near-duplicate devices do (organized-ideas.md §3). Port/cable types
// aren't included — nothing so far has suggested they drift the same way.
const duplicates = useMemo(() => {
if (isEditToPublicEntry) return []
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : ''
if (!proposedName) return []
const candidates =
submission.entityType === 'device_template'
? catalog.deviceTemplates.filter((dt) => !dt.custom && dt.id !== submission.entityId)
: submission.entityType === 'device_category'
? catalog.deviceCategories.filter((c) => !c.custom && c.id !== submission.entityId)
: submission.entityType === 'manufacturer'
? catalog.manufacturers.filter((m) => !m.custom && m.id !== submission.entityId)
: []
return findLikelyDuplicates(
proposedName,
candidates.map((c) => ({ id: c.id, name: c.name })),
)
}, [
isEditToPublicEntry,
submission.entityType,
submission.entityId,
submission.proposedData,
catalog.deviceTemplates,
catalog.deviceCategories,
catalog.manufacturers,
])
const handleApprove = async () => {
setBusy(true)
try {
await approve(submission)
} finally {
setBusy(false)
}
}
const handleReject = async () => {
if (!reason.trim()) return
setBusy(true)
try {
await reject(submission.id, reason.trim())
} finally {
setBusy(false)
setRejecting(false)
setReason('')
}
}
const displayName = currentEntity?.name ?? (typeof submission.proposedData.name === 'string' ? submission.proposedData.name : submission.entityId)
return (
<div className="rounded border border-slate-200 bg-white p-3 text-xs">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-700">{displayName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500">
{ENTITY_TYPE_LABELS[submission.entityType]}
</span>
{!isEditToPublicEntry && (
<span className="rounded bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-600">
New public entry
</span>
)}
</div>
<div className="mt-0.5 text-[10px] text-slate-400">
{submitterUsername ?? 'Unknown user'} · submitted {formatDate(submission.createdAt)}
</div>
</div>
</div>
{!currentEntity && (
<p className="mt-2 rounded bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
The underlying entry no longer exists — this submission can only be rejected.
</p>
)}
{diffs.length > 0 && (
<div className="mt-2 space-y-1 rounded border border-slate-100 bg-slate-50 p-2">
{diffs.map((d) => (
<div key={d.key} className="flex flex-wrap items-baseline gap-x-1.5 text-[11px]">
<span className="font-medium text-slate-500">{fieldLabel(d.key)}:</span>
<span className="text-slate-400 line-through">{formatDiffValue(d.key, d.before)}</span>
<span className="text-slate-400">→</span>
<span className="font-medium text-slate-700">{formatDiffValue(d.key, d.after)}</span>
</div>
))}
</div>
)}
{diffs.length === 0 && currentEntity && (
<p className="mt-2 text-[11px] italic text-slate-400">No changes proposed beyond publishing this as-is.</p>
)}
{duplicates.length > 0 && (
<p className="mt-2 rounded bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
<span className="font-medium">Possible duplicate of: </span>
{duplicates.map((d) => d.name).join(', ')}
</p>
)}
{isEditToPublicEntry && (
<div className="mt-2 rounded border border-slate-100 bg-slate-50 px-2 py-1.5 text-[11px]">
{impactLoading ? (
<span className="text-slate-400">Checking how many diagrams use this…</span>
) : impact ? (
impact.diagramCount === 0 ? (
<span className="text-slate-500">Not currently used in any diagram.</span>
) : (
<div>
<span className="font-medium text-amber-700">
Used in {impact.diagramCount} diagram{impact.diagramCount === 1 ? '' : 's'}:
</span>{' '}
<span className="text-slate-600">
{impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}
{impact.diagramCount > impact.sample.length ? ', …' : ''}
</span>
</div>
)
) : null}
</div>
)}
{rejecting ? (
<div className="mt-2 space-y-1.5">
<textarea
autoFocus
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (shown to the submitter, and required)"
rows={2}
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<div className="flex justify-end gap-2">
<button onClick={() => setRejecting(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={handleReject}
disabled={busy || !reason.trim()}
className="rounded bg-red-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Confirm reject
</button>
</div>
</div>
) : (
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => setRejecting(true)}
disabled={busy}
className="rounded bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Reject
</button>
<button
onClick={handleApprove}
disabled={busy || !currentEntity}
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Approve
</button>
</div>
)}
</div>
)
}
export default function AdminReviewModal({ onClose }: { onClose: () => void }) {
const catalog = useCatalogStore((s) => s.catalog)
const allSubmissions = useAdminReviewStore((s) => s.allSubmissions)
const submitterUsernames = useAdminReviewStore((s) => s.submitterUsernames)
// Oldest first — a review queue should drain FIFO, not bury older
// submissions under newer ones.
const pending = useMemo(
() =>
allSubmissions
.filter((s) => s.status === 'pending')
.sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[allSubmissions],
)
return (
<Modal title="Review Queue" onClose={onClose} width="max-w-2xl">
<div className="space-y-2">
{pending.length === 0 && <p className="py-2 text-center text-xs italic text-slate-400">Nothing pending review.</p>}
{pending.map((submission) => (
<SubmissionReviewCard
key={submission.id}
submission={submission}
catalog={catalog}
submitterUsername={submitterUsernames[submission.id]}
/>
))}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,291 @@
import { useState } from 'react'
import { cableTypeToRow, portTypeToRow, rowToCableTypeFields, rowToDeviceTemplateFields, rowToPortTypeFields } from '../../data/catalogRowMapping'
import type { CatalogSubmission } from '../../data/SubmissionRepository'
import { allPortTypes } from '../../domain/diagram'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore'
import { useSubmissionStore } from '../../state/submissionStore'
import { resolveCableTypeFields } from '../palette/cableTypeFormFields'
import { CableTypeForm } from '../palette/CableTypeManager'
import DeviceTemplateEditor from '../palette/DeviceTemplateEditor'
import { PortTypeForm } from '../palette/PortTypeManager'
import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup'
import Modal from '../common/Modal'
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
device_template: 'Device',
port_type: 'Port type',
cable_type: 'Cable type',
device_category: 'Category',
manufacturer: 'Manufacturer',
}
/** Best-effort display name: the entity's current live name if it still
* exists in the catalog, else whatever name was proposed (it always will
* exist in this app's flow — entity_id always points at a real row — but
* falling back is cheap insurance). */
function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): string {
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : undefined
return findCatalogEntity(catalog, submission.entityType, submission.entityId)?.name ?? proposedName ?? submission.entityId
}
/** True when this submission is about an entry you still privately own —
* i.e. "promote my own entry" rather than "suggest an edit to a public
* entry you don't own". For that case the library entry (editable via the
* normal palette/managers) is the single source of truth; catalogStore
* keeps proposed_data synced to it automatically (see syncProposedData),
* so there's no separate draft to edit here. */
function isOwnPrivateEntry(catalog: Catalog, submission: CatalogSubmission): boolean {
return findCatalogEntity(catalog, submission.entityType, submission.entityId)?.custom === true
}
/** Row-shaped snapshot of the live entity, for the "Resubmit" action on an
* own-private-entry submission — re-confirms the (already-synced)
* proposed_data and flips status back to pending in one explicit click. */
function liveEntityRow(catalog: Catalog, submission: CatalogSubmission): Record<string, unknown> | undefined {
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId)
return entity ? catalogEntityToRow(submission.entityType, entity) : undefined
}
function statusBadge(submission: CatalogSubmission) {
switch (submission.status) {
case 'pending':
return <span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">Pending</span>
case 'approved':
return <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">Approved</span>
case 'rejected':
return <span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">Rejected</span>
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
export default function MySubmissionsModal({ onClose }: { onClose: () => void }) {
const catalog = useCatalogStore((s) => s.catalog)
const updateCustomPortType = useCatalogStore((s) => s.updateCustomPortType)
const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const withdraw = useSubmissionStore((s) => s.withdraw)
const resubmit = useSubmissionStore((s) => s.resubmit)
const [editingId, setEditingId] = useState<string | null>(null)
const [nameDraft, setNameDraft] = useState('')
const allPortTypesList = allPortTypes(catalog)
const handleWithdraw = (submission: CatalogSubmission) => {
const verb = submission.status === 'rejected' ? 'Dismiss' : 'Withdraw'
const confirmed = window.confirm(`${verb} this submission for "${entityDisplayName(catalog, submission)}"?`)
if (confirmed) withdraw(submission.id)
}
const startEditing = (submission: CatalogSubmission) => {
setNameDraft(typeof submission.proposedData.name === 'string' ? submission.proposedData.name : '')
setEditingId(submission.id)
}
return (
<Modal title="My Submissions" onClose={onClose} width="max-w-lg">
<div className="space-y-2">
{mySubmissions.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">
No submissions yet — use "Submit" or "Suggest edit" on a catalog entry to propose it for the public
catalog.
</p>
)}
{mySubmissions.map((submission) => {
const isEditing = editingId === submission.id
const ownEntry = isOwnPrivateEntry(catalog, submission)
// For an own-private-entry submission, the library entry is the
// only place to edit — this "revise" form only ever applies to
// suggesting an edit to a public entry you don't own.
const canRevise = !ownEntry && (submission.status === 'pending' || submission.status === 'rejected')
// Own-private-entry editing: the exact same form + save action as
// the device library's own "Edit" button (direct catalogStore
// update, not a submission-specific save path) — catalogStore's
// update actions already keep this submission's proposed_data in
// sync afterward (see syncProposedData), so nothing extra is
// needed here beyond editing the live entry itself.
if (isEditing && ownEntry && submission.entityType === 'port_type') {
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as PortType
return (
<PortTypeForm
key={submission.id}
portType={entity}
allTypes={allPortTypesList}
onCancel={() => setEditingId(null)}
onSave={(fields) => {
updateCustomPortType(submission.entityId, fields)
setEditingId(null)
}}
/>
)
}
if (isEditing && ownEntry && submission.entityType === 'cable_type') {
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as CableType
return (
<CableTypeForm
key={submission.id}
initial={entity}
portTypes={allPortTypesList}
onCancel={() => setEditingId(null)}
onSave={(fields) => {
const resolved = resolveCableTypeFields(fields, allPortTypesList)
if (!resolved) return
updateCustomCableType(submission.entityId, resolved)
setEditingId(null)
}}
/>
)
}
if (isEditing && ownEntry && submission.entityType === 'device_template') {
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as DeviceTemplate
return <DeviceTemplateEditor key={submission.id} template={entity} onClose={() => setEditingId(null)} />
}
// Suggest-an-edit-to-a-public-entry editing: a separate draft
// (proposed_data) since there's no live private row to defer to —
// saving here revises the submission itself.
if (isEditing && submission.entityType === 'port_type') {
return (
<PortTypeForm
key={submission.id}
portType={{ id: submission.entityId, custom: false, ...rowToPortTypeFields(submission.proposedData) }}
allTypes={allPortTypesList}
onCancel={() => setEditingId(null)}
onSave={(fields) => {
resubmit(submission.id, portTypeToRow(fields))
setEditingId(null)
}}
/>
)
}
if (isEditing && submission.entityType === 'cable_type') {
return (
<CableTypeForm
key={submission.id}
initial={{ id: submission.entityId, custom: false, ...rowToCableTypeFields(submission.proposedData) }}
portTypes={allPortTypesList}
onCancel={() => setEditingId(null)}
onSave={(fields) => {
const resolved = resolveCableTypeFields(fields, allPortTypesList)
if (!resolved) return
resubmit(submission.id, cableTypeToRow(resolved))
setEditingId(null)
}}
/>
)
}
if (isEditing && submission.entityType === 'device_template') {
return (
<DeviceTemplateEditor
key={submission.id}
template={{ id: submission.entityId, custom: false, ...rowToDeviceTemplateFields(submission.proposedData) }}
resubmitId={submission.id}
onClose={() => setEditingId(null)}
onSaved={() => setEditingId(null)}
/>
)
}
if (isEditing && (submission.entityType === 'device_category' || submission.entityType === 'manufacturer')) {
return (
<div key={submission.id} className="flex items-center gap-1.5 rounded border border-indigo-200 bg-indigo-50/40 p-2">
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<button onClick={() => setEditingId(null)} className="shrink-0 text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={() => {
if (!nameDraft.trim()) return
resubmit(submission.id, { name: nameDraft.trim() })
setEditingId(null)
}}
className="shrink-0 rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
>
Resubmit
</button>
</div>
)
}
return (
<div key={submission.id} className="rounded border border-slate-200 bg-white p-2 text-xs">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium text-slate-700">{entityDisplayName(catalog, submission)}</span>
{statusBadge(submission)}
</div>
<div className="text-[10px] text-slate-400">
{ENTITY_TYPE_LABELS[submission.entityType]} · submitted {formatDate(submission.createdAt)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
<button
onClick={() => startEditing(submission)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Edit
</button>
)}
{ownEntry && submission.status === 'rejected' && (
<button
onClick={() => {
const row = liveEntityRow(catalog, submission)
if (row) resubmit(submission.id, row)
}}
title="Resubmit your current library entry for review"
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
Resubmit
</button>
)}
{canRevise && (
<button
onClick={() => startEditing(submission)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700"
>
{submission.status === 'rejected' ? 'Revise & resubmit' : 'Edit'}
</button>
)}
{(submission.status === 'pending' || submission.status === 'rejected') && (
<button
onClick={() => handleWithdraw(submission)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
{submission.status === 'rejected' ? 'Dismiss' : 'Withdraw'}
</button>
)}
</div>
</div>
{ownEntry && (submission.status === 'pending' || submission.status === 'rejected') && (
<p className="mt-1.5 text-[10px] text-slate-400">
Tracks your library entry — editing it here (or from the palette) keeps this submission up to date automatically.
</p>
)}
{submission.status === 'rejected' && submission.reviewReason && (
<p className="mt-1.5 rounded bg-red-50 px-2 py-1 text-[11px] text-red-700">
<span className="font-medium">Reason: </span>
{submission.reviewReason}
</p>
)}
</div>
)
})}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,42 @@
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../../domain/types'
export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef | Manufacturer
/** Looks up a submission's entity in the current catalog (public or your
* own private) by (entityType, entityId) — shared by MySubmissionsModal
* and AdminReviewModal so "what does this submission reference right now"
* is answered the same way everywhere. */
export function findCatalogEntity(catalog: Catalog, entityType: CatalogEntityType, entityId: string): CatalogEntity | undefined {
switch (entityType) {
case 'port_type':
return catalog.portTypes.find((pt) => pt.id === entityId)
case 'cable_type':
return catalog.cableTypes.find((ct) => ct.id === entityId)
case 'device_template':
return catalog.deviceTemplates.find((dt) => dt.id === entityId)
case 'device_category':
return catalog.deviceCategories.find((c) => c.id === entityId)
case 'manufacturer':
return catalog.manufacturers.find((m) => m.id === entityId)
}
}
/** Row-shaped snapshot of a live catalog entity (see data/catalogRowMapping.ts)
* — the same shape a submission's `proposed_data` is stored in, so the two
* can be diffed or interchanged directly. */
export function catalogEntityToRow(entityType: CatalogEntityType, entity: CatalogEntity): Record<string, unknown> {
switch (entityType) {
case 'port_type':
return portTypeToRow(entity as PortType)
case 'cable_type':
return cableTypeToRow(entity as CableType)
case 'device_template':
return deviceTemplateToRow(entity as DeviceTemplate)
case 'device_category':
return deviceCategoryToRow(entity.name)
case 'manufacturer':
return manufacturerToRow(entity.name)
}
}
@@ -0,0 +1,44 @@
/** A light duplicate-detection nudge for Admins reviewing a new device
* submission (organized-ideas.md §3) — fuzzy-matches the proposed name
* against existing public entries so the catalog doesn't slowly fill with
* near-identical entries. Deliberately simple (no server round trip, no
* new Postgres extension): normalize + Levenshtein distance against
* already-loaded public names, which is plenty for a "possible duplicate,
* take a look" nudge rather than a hard duplicate check. */
function normalize(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
}
function levenshteinDistance(a: string, b: string): number {
const rows = a.length + 1
const cols = b.length + 1
const dp: number[][] = Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
for (let i = 0; i < rows; i++) dp[i][0] = i
for (let j = 0; j < cols; j++) dp[0][j] = j
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
return dp[rows - 1][cols - 1]
}
export interface DuplicateCandidate {
id: string
name: string
}
/** Candidates within a generous edit-distance threshold of `name` — a near-
* rename or typo, not just anything vaguely similar. */
export function findLikelyDuplicates(name: string, candidates: DuplicateCandidate[], maxResults = 3): DuplicateCandidate[] {
const target = normalize(name)
if (!target) return []
const threshold = Math.max(2, Math.floor(target.length * 0.3))
return candidates
.map((c) => ({ candidate: c, distance: levenshteinDistance(target, normalize(c.name)) }))
.filter(({ distance }) => distance <= threshold)
.sort((a, b) => a.distance - b.distance)
.slice(0, maxResults)
.map(({ candidate }) => candidate)
}
@@ -0,0 +1,57 @@
/** One changed field between a submission's current live row and its
* proposed_data — used to render the Admin review queue's diff view. */
export interface FieldDiff {
key: string
before: unknown
after: unknown
}
/** Field-by-field diff between two row-shaped objects (see
* data/catalogRowMapping.ts) — only fields that actually differ are
* returned, so a submission that only changes one thing shows one row. */
export function diffRows(current: Record<string, unknown>, proposed: Record<string, unknown>): FieldDiff[] {
const keys = new Set([...Object.keys(current), ...Object.keys(proposed)])
const diffs: FieldDiff[] = []
for (const key of keys) {
const before = current[key]
const after = proposed[key]
if (JSON.stringify(before) !== JSON.stringify(after)) diffs.push({ key, before, after })
}
return diffs
}
const FIELD_LABELS: Record<string, string> = {
name: 'Name',
category: 'Category',
category_id: 'Category',
family: 'Family',
family2: 'Family (end 2)',
compatible_family_ids: 'Can accept connections from',
max_connections: 'Max connections',
unit: 'Unit',
cost_per_unit: 'Cost per unit',
manufacturer_id: 'Manufacturer',
model: 'Model',
cost: 'Cost',
ports: 'Ports',
}
export function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? key
}
/** Formats a diffed field's value for display — arrays and port lists get a
* readable summary rather than raw JSON. */
export function formatDiffValue(key: string, value: unknown): string {
if (value === null || value === undefined) return '—'
if (key === 'ports' && Array.isArray(value)) {
return value
.map((p) => {
const port = p as { name?: string; direction?: string; port_type_id?: string }
return `${port.name ?? '?'} (${port.direction ?? '?'}, ${port.port_type_id ?? '?'})`
})
.join('; ')
}
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—'
return String(value)
}
+39
View File
@@ -0,0 +1,39 @@
import type { CatalogSubmission } from './SubmissionRepository'
/** One entry in a usage-impact sample — never raw diagram content, just
* enough to identify it (see organized-ideas.md §3's impact-check). */
export interface UsageImpactSample {
id: string
name: string
ownerUsername: string
}
export interface UsageImpact {
/** Total diagrams referencing this entity — not capped, unlike `sample`. */
diagramCount: number
/** Up to 5 of the most recently updated referencing diagrams. */
sample: UsageImpactSample[]
}
/** Storage abstraction for the Admin's-eye view of the catalog submission
* queue — mirrors SubmissionRepository's role for the submitter's-eye
* view. Every method here relies on the caller actually being an Admin;
* RLS (and, for the impact function, an explicit is_admin() check) is the
* real enforcement, not this interface. */
export interface AdminSubmissionRepository {
/** Every submission across all users, most recent first. */
listAll(): Promise<CatalogSubmission[]>
/** Approves a submission: writes its proposed_data onto the live row at
* entity_id — promoting it to public in place if it wasn't already —
* and marks the submission approved. Same id throughout; never creates
* a duplicate public row (organized-ideas.md §3). */
approve(submission: CatalogSubmission): Promise<void>
/** Rejects a submission with a reason the submitter will see. */
reject(id: string, reason: string): Promise<void>
/** How many diagrams reference this entity, and a small sample — see
* organized-ideas.md §3's impact-check-before-editing. */
getUsageImpact(entityType: CatalogSubmission['entityType'], entityId: string): Promise<UsageImpact>
/** Submitter username per submission id, for the ones a username could
* be resolved for (batched — one round trip for a whole queue listing). */
getSubmitterUsernames(submissionIds: string[]): Promise<Record<string, string>>
}
+80
View File
@@ -0,0 +1,80 @@
/** A user's role — mirrors `profiles.role`'s check constraint. Defined here
* (data layer) rather than in state/authStore.ts so both that store and
* this repository share one definition without state importing from data
* in the wrong direction. */
export type UserRole = 'regular' | 'admin' | 'super_admin'
export interface AdminUserSummary {
id: string
username: string
email: string
role: UserRole
/** Lets this specific Admin post/retire site-wide announcements — a
* narrower grant than the role itself (a Super Admin can always post
* regardless of this). Meaningless for a regular/super_admin row, but
* present either way since it mirrors the underlying profiles column. */
canPostAnnouncements: boolean
/** Set (a future timestamp) while banned; undefined otherwise. */
bannedUntil?: string
createdAt: string
/** Set once this account's data has been moved to another account via
* migrateAccount below (organized-ideas.md §2) — never cleared, and
* never set back to undefined by anything in this app. */
migratedToUserId?: string
migratedToUsername?: string
}
export interface AccountMigrationImpact {
diagramCount: number
privateEntityCount: number
submissionCount: number
}
/** One row of the permanent account-migration audit log. */
export interface AccountMigrationRecord extends AccountMigrationImpact {
id: string
/** Null if that account has since been deleted — fromUsername/toUsername
* (snapshotted at migration time) stay populated regardless, so the log
* stays legible either way. */
fromUserId: string | null
toUserId: string | null
fromUsername: string
toUsername: string
performedBy: string | null
verificationNotes: string
createdAt: string
}
/** Storage abstraction for Super-Admin user management (organized-ideas.md
* §6's "CRUD user accounts"). Listing and role changes are plain
* RLS/privileged-function reads and writes; ban/unban/delete go through
* the admin-user-action Edge Function since those specifically need
* Supabase Auth's Admin API — see that function's own header comment for
* why this can't just be another SQL function like the rest. */
export interface AdminUserRepository {
listUsers(): Promise<AdminUserSummary[]>
updateRole(userId: string, role: UserRole): Promise<void>
/** Super-Admin-only in practice (RLS), same as updateRole — grants or
* revokes one Admin's ability to post announcements. */
updateCanPostAnnouncements(userId: string, canPostAnnouncements: boolean): Promise<void>
/** Reversible — blocks login without touching the account's data. */
banUser(userId: string): Promise<void>
unbanUser(userId: string): Promise<void>
/** Irreversible — cascades to the user's profile, diagrams, and owned
* private catalog entries via their existing foreign keys. */
deleteUser(userId: string): Promise<void>
/** Read-only "what would move" check before committing a migration below —
* same idea as the catalog usage-impact check before an unpublish. */
previewAccountMigration(fromUserId: string, toUserId: string): Promise<AccountMigrationImpact>
/** Moves diagrams, private catalog entries, and submissions from one
* account to another (organized-ideas.md §2's tool for someone who's
* lost access to their old account) — never touches credentials or
* deletes the old account, just reassigns what it owns. Throws (rather
* than swallowing, unlike most methods here) so the caller can surface
* *why* it failed — most commonly: already migrated, missing
* verification notes, or migrating an account into itself. */
migrateAccount(fromUserId: string, toUserId: string, verificationNotes: string): Promise<AccountMigrationImpact>
/** The permanent audit trail, most recent first. */
listAccountMigrations(): Promise<AccountMigrationRecord[]>
}
+30
View File
@@ -0,0 +1,30 @@
/** The current site-wide announcement (organized-ideas.md §3), if any, from
* the signed-in user's own point of view — `dismissed` reflects only their
* own dismissal, since that's tracked per-account, per-announcement. */
export interface Announcement {
id: string
message: string
createdAt: string
dismissed: boolean
}
/**
* Storage abstraction for site-wide announcements. Posting/retiring is
* gated server-side by `can_manage_announcements()` (every Super Admin, or
* an Admin individually flagged via `profiles.can_post_announcements`) —
* this interface doesn't re-check that, same as every other repository
* here leaving authorization to RLS.
*/
export interface AnnouncementRepository {
/** The current (unretired) announcement, or null if none. */
getCurrent(): Promise<Announcement | null>
/** Posts a new announcement. A DB trigger retires whichever one was
* previously current, so there's always at most one current at a time. */
post(message: string): Promise<void>
/** Retires the current announcement early, without posting a replacement. */
retireCurrent(): Promise<void>
/** Records that the signed-in user has dismissed this announcement —
* permanent for that announcement id, but doesn't affect whatever gets
* posted next (a new id starts undismissed for everyone again). */
dismiss(announcementId: string): Promise<void>
}
+96
View File
@@ -0,0 +1,96 @@
import type { CatalogEntityType } from './SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, PortType } from '../domain/types'
/**
* Storage abstraction for the global port/cable/category/device-template
* catalog — mirrors DiagramRepository's role, but for catalog data instead
* of diagrams. Fetching returns everything the current user can see (public
* entries plus their own private ones); RLS does that filtering server-side,
* so implementations don't need to filter client-side.
*
* The `add*`/`update*` methods always create or edit an `is_public = false`
* row you own — that's the regular-user "own private catalog" surface.
* Getting an entry into the public catalog otherwise goes through the
* submission/review workflow (organized-ideas.md §3), except for the
* `admin*` methods below: per §6's capability table, an Admin/Super-Admin
* can also CRUD public entries directly, without a submission — those
* bypass ownership entirely (RLS's `is_admin()` clause is what actually
* allows it) and only ever touch already-public rows.
*/
export interface CatalogRepository {
/** Everything visible to the current user: public entries plus their own private ones. */
load(): Promise<Catalog>
addDeviceCategory(name: string): Promise<DeviceCategoryDef>
updateDeviceCategory(id: string, name: string): Promise<void>
/** Throws (rather than swallowing, like most methods here) if the delete
* is rejected — most commonly a foreign-key violation because one of
* your device templates still references this category, which the
* caller needs to know about to tell you why nothing happened. */
removeDeviceCategory(id: string): Promise<void>
addManufacturer(name: string): Promise<Manufacturer>
updateManufacturer(id: string, name: string): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory. */
removeManufacturer(id: string): Promise<void>
addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
/** Same throw-on-failure contract as removeDeviceCategory — most commonly
* a foreign-key violation because some device template's port still
* references it. */
removePortType(id: string): Promise<void>
addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
removeCableType(id: string): Promise<void>
addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
removeDeviceTemplate(id: string): Promise<void>
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow entirely, for authoring the shared library yourself
* (e.g. building out the initial device catalog) rather than reviewing
* someone else's proposal. Same RLS branch that already allows an Admin
* to directly edit a public entry (`is_admin() and is_public`) also
* covers inserting one — unlike adminUpdate* below, this is a genuinely
* different write, not just an alias (is_public: true, owner_id: null
* from the start). */
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef>
adminAddManufacturer(name: string): Promise<Manufacturer>
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType>
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType>
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate>
/** Admin/Super-Admin direct edit of an already-public port/cable/device
* entry — same underlying write as approving a submission, minus the
* submission. */
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void>
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void>
adminUpdateDeviceCategory(id: string, name: string): Promise<void>
adminUpdateManufacturer(id: string, name: string): Promise<void>
/** Unpublishes a public entry (is_public -> false) rather than deleting
* it — organized-ideas.md §3's "public catalog entries are never hard-
* deleted, only hidden/unpublished". Existing diagrams that reference it
* by id are unaffected; it just stops being offered for new use. */
adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void>
/** Admin/Super-Admin permanent delete of a built-in (public) entry —
* narrower than adminUnpublish: the caller is expected to have already
* verified zero usage (no diagram references it, nothing else in the
* catalog does either) before calling this, same gate already applied to
* a regular user deleting their own private entry. Doesn't relax
* organized-ideas.md §3's "never break an existing diagram" principle —
* it only ever removes something genuinely unused (e.g. a mistake made
* while authoring the library), which is why this exists alongside
* adminUnpublish rather than instead of it. Same RLS branch as the other
* admin* methods (`is_admin()`), so these are aliases of the plain
* remove* methods above, not separate logic. Throws on failure. */
adminRemoveDeviceCategory(id: string): Promise<void>
adminRemoveManufacturer(id: string): Promise<void>
adminRemovePortType(id: string): Promise<void>
adminRemoveCableType(id: string): Promise<void>
adminRemoveDeviceTemplate(id: string): Promise<void>
}
+31
View File
@@ -0,0 +1,31 @@
export type CollaboratorPermission = 'view' | 'edit'
export interface DiagramCollaborator {
userId: string
username: string
permission: CollaboratorPermission
}
/** Thrown by `add` when the given username doesn't resolve to any account —
* a normal, expected outcome (typo, or the person hasn't signed up yet),
* not a system failure. */
export class UnknownUsernameError extends Error {}
/** Thrown by `add` when the resolved username is the diagram owner's own
* account — RLS blocks this at the database level too (the real
* enforcement), this is just a friendlier message than the raw 42501. */
export class SelfCollaboratorError extends Error {}
/** Storage abstraction for a diagram's collaborator list, per
* organized-ideas.md §8: the owner shares with specific people by
* username, choosing view or edit access per person. RLS restricts
* add/update/remove to the diagram's owner (or a Super Admin) — this
* interface doesn't re-check that, it just reflects what the backend allows. */
export interface DiagramCollaboratorRepository {
list(diagramId: string): Promise<DiagramCollaborator[]>
/** Resolves `username` to an account and adds them with `permission`.
* Throws UnknownUsernameError if no such username exists. */
add(diagramId: string, username: string, permission: CollaboratorPermission): Promise<DiagramCollaborator>
updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise<void>
remove(diagramId: string, userId: string): Promise<void>
}
+37
View File
@@ -0,0 +1,37 @@
import type { Diagram } from '../domain/types'
/** Lightweight metadata for listing diagrams without fetching each one's
* full (potentially large) data payload. */
export interface DiagramSummary {
id: string
name: string
updatedAt: string
}
/** Your relationship to a diagram — who owns it, and what you personally
* can do with it (organized-ideas.md §8's per-collaborator view/edit
* permissions). Drives the frontend's own view-only lockdown, since RLS
* silently no-ops a blocked write rather than erroring — the UI needs to
* know *before* the user tries, not just fail quietly after. */
export interface DiagramAccess {
ownerId: string
myPermission: 'owner' | 'edit' | 'view'
}
/**
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
* know or care which one is active.
*/
export interface DiagramRepository {
/** Every diagram visible to the current user, most recently updated first. */
list(): Promise<DiagramSummary[]>
/** One diagram's full data by id, or null if it doesn't exist / isn't visible. */
loadById(id: string): Promise<Diagram | null>
/** Upsert a diagram, matched by its own id. */
save(diagram: Diagram): Promise<void>
/** Permanently delete one diagram by id. */
deleteById(id: string): Promise<void>
/** Who owns this diagram and what the current user can do with it. */
getAccess(id: string): Promise<DiagramAccess | null>
}
+26
View File
@@ -0,0 +1,26 @@
import type { Diagram } from '../domain/types'
export interface DiagramSnapshotSummary {
id: string
createdAt: string
savedByUsername?: string
}
/** Storage abstraction for a diagram's rolling snapshot history —
* organized-ideas.md §8's undo/recovery safety net, now that turn-based
* collaborators can edit a diagram too. Writing snapshots happens as a
* side effect of normal saves (see SupabaseDiagramRepository.save), not
* through this interface — this is purely the read/restore side. */
export interface DiagramSnapshotRepository {
/** Most recent first. */
list(diagramId: string): Promise<DiagramSnapshotSummary[]>
/** Restores a snapshot as the diagram's current data. When
* `writeSafetySnapshot` is true, the pre-restore state is snapshotted
* first so restoring is itself undoable — the caller passes false when
* it already knows the live state is an unmodified copy of a snapshot
* that still exists (e.g. restoring again right after an earlier
* restore, with no edits in between), so a second copy of it would just
* be a duplicate. Returns the restored Diagram so the caller can update
* the open diagram in place. */
restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Diagram | null>
}
+59
View File
@@ -0,0 +1,59 @@
import type { Diagram } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeDiagram } from './exportImport'
const STORAGE_KEY = 'av-planner:diagrams:v1'
type StoredDiagrams = Record<string, Diagram>
function readAll(): StoredDiagrams {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as StoredDiagrams) : {}
} catch (err) {
console.error('Failed to read diagrams from localStorage', err)
return {}
}
}
function writeAll(diagrams: StoredDiagrams): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(diagrams))
}
/** Kept around (no longer the active repository — see state/diagramStore.ts)
* as a reference implementation and a safe fallback if Supabase is
* unreachable. Multiple diagrams are stored as one JSON object keyed by
* id, matching the shape the Supabase-backed repository presents to the
* rest of the app. */
export class LocalStorageDiagramRepository implements DiagramRepository {
async list(): Promise<DiagramSummary[]> {
const diagrams = Object.values(readAll())
return diagrams
.map((d) => ({ id: d.id, name: d.name, updatedAt: d.updatedAt }))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
async loadById(id: string): Promise<Diagram | null> {
const diagram = readAll()[id]
return diagram ? normalizeDiagram(diagram) : null
}
async save(diagram: Diagram): Promise<void> {
const all = readAll()
all[diagram.id] = diagram
writeAll(all)
}
async deleteById(id: string): Promise<void> {
const all = readAll()
delete all[id]
writeAll(all)
}
/** localStorage is inherently single-user — always the owner, never a
* shared collaborator. */
async getAccess(id: string): Promise<DiagramAccess | null> {
const diagram = readAll()[id]
return diagram ? { ownerId: 'local', myPermission: 'owner' } : null
}
}
-26
View File
@@ -1,26 +0,0 @@
import type { Project } from '../domain/types'
import { normalizeProject } from './exportImport'
import type { ProjectRepository } from './ProjectRepository'
const STORAGE_KEY = 'av-planner:project:v1'
export class LocalStorageProjectRepository implements ProjectRepository {
async load(): Promise<Project | null> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return normalizeProject(JSON.parse(raw) as Project)
} catch (err) {
console.error('Failed to load project from localStorage', err)
return null
}
}
async save(project: Project): Promise<void> {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
}
async clear(): Promise<void> {
window.localStorage.removeItem(STORAGE_KEY)
}
}
-13
View File
@@ -1,13 +0,0 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. Today only
* `LocalStorageProjectRepository` implements it; a future backend would add
* e.g. `ApiProjectRepository` (REST/GraphQL) behind this same interface so
* the store/UI layers wouldn't need to change.
*/
export interface ProjectRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
+57
View File
@@ -0,0 +1,57 @@
/** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s
* check constraint. */
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category' | 'manufacturer'
/**
* A user's request to change the public catalog — either promoting one of
* their own private entries to public, or proposing an edit to an existing
* public entry. Regular users can never write a public catalog row directly
* (RLS only allows Admins to), so this is the only path either kind of
* change can take; an Admin review queue (not yet built — organized-ideas.md
* §9's next sub-phase) is what actually applies an approved one.
*/
export interface CatalogSubmission {
id: string
entityType: CatalogEntityType
/** The row this submission is about — always an existing row in this
* app's flow: either your own private entry (being promoted, possibly
* with edits) or a public entry (you're suggesting an edit to). */
entityId: string
/** Proposed field values, shaped like the underlying table's row (see
* data/catalogRowMapping.ts) — so applying an approval is just writing
* this object onto the row at `entityId`, not a separate translation
* step future Admin-approval logic would otherwise have to redo. */
proposedData: Record<string, unknown>
submitterId: string
status: 'pending' | 'approved' | 'rejected'
reviewerId?: string
/** Set by an Admin on rejection — organized-ideas.md §3's "notified either
* way, with a reason attached on rejection rather than a silent disappearance." */
reviewReason?: string
createdAt: string
updatedAt: string
}
/** Storage abstraction for the catalog submission/review queue — mirrors
* DiagramRepository/CatalogRepository's role for this concern. Only covers
* the submitter's-eye view (create, revise, withdraw your own) for now; an
* Admin's view (list all pending, approve/reject) is the next sub-phase. */
export interface SubmissionRepository {
/** Every submission the current user has made, most recent first. */
listMine(): Promise<CatalogSubmission[]>
/** Submits `entityId` (a row you own privately, or a public row you're
* proposing an edit to) for Admin review. */
submit(entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>): Promise<CatalogSubmission>
/** Revises a still-pending or previously-rejected submission of your own
* and puts it (back) into the pending queue. */
resubmit(id: string, proposedData: Record<string, unknown>): Promise<void>
/** Updates only `proposed_data`, leaving status/reviewer/reason untouched.
* Used to keep a submission's snapshot in sync with the live private
* entry it's about (see catalogStore) — a plain edit to your library
* entry shouldn't silently pull a rejected submission back into the
* pending queue, so status changes stay a separate, explicit action
* (`resubmit`), while the data itself is kept fresh automatically. */
updateProposedData(id: string, proposedData: Record<string, unknown>): Promise<void>
/** Withdraws your own still-pending submission. */
withdraw(id: string): Promise<void>
}
@@ -0,0 +1,119 @@
import { v4 as uuid } from 'uuid'
import type { AdminSubmissionRepository, UsageImpact, UsageImpactSample } from './AdminSubmissionRepository'
import { CATALOG_TABLE_BY_ENTITY_TYPE } from './catalogRowMapping'
import type { CatalogSubmission } from './SubmissionRepository'
import { supabase } from './supabaseClient'
import { toSubmission, type SubmissionRow } from './submissionRowMapping'
interface ProposedDeviceTemplatePort {
name: string
direction: string
port_type_id: string
}
/** Backs the app with the `catalog_submissions` table (Admin's-eye view)
* plus the catalog tables it approves onto and the usage-impact RPC. RLS's
* `is_admin()` clauses are what actually gate every write here — this
* class assumes the caller already is one. */
export class SupabaseAdminSubmissionRepository implements AdminSubmissionRepository {
async listAll(): Promise<CatalogSubmission[]> {
const { data, error } = await supabase
.from('catalog_submissions')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Failed to load submissions from Supabase', error)
return []
}
return ((data ?? []) as SubmissionRow[]).map(toSubmission)
}
async approve(submission: CatalogSubmission): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
// proposed_data is already row-shaped (see data/catalogRowMapping.ts) —
// approving is just writing it onto the live row, flipping it public.
// `ports` (device_template only) isn't a column on device_templates
// itself; pull it out and replace device_template_ports separately.
const { ports, ...rowPatch } = submission.proposedData as Record<string, unknown> & { ports?: ProposedDeviceTemplatePort[] }
const table = CATALOG_TABLE_BY_ENTITY_TYPE[submission.entityType]
const { error } = await supabase
.from(table)
.update({ ...rowPatch, is_public: true, owner_id: null })
.eq('id', submission.entityId)
if (error) {
console.error('Failed to approve submission (entity update) in Supabase', error)
throw error
}
if (submission.entityType === 'device_template') {
const { error: deleteError } = await supabase
.from('device_template_ports')
.delete()
.eq('device_template_id', submission.entityId)
if (deleteError) console.error('Failed to clear device template ports while approving', deleteError)
if (ports && ports.length > 0) {
const { error: insertError } = await supabase.from('device_template_ports').insert(
ports.map((port, index) => ({
id: uuid(),
device_template_id: submission.entityId,
name: port.name,
direction: port.direction,
port_type_id: port.port_type_id,
sort_order: index,
})),
)
if (insertError) console.error('Failed to write device template ports while approving', insertError)
}
}
const { error: statusError } = await supabase
.from('catalog_submissions')
.update({ status: 'approved', reviewer_id: user.id, review_reason: null })
.eq('id', submission.id)
if (statusError) console.error('Failed to mark submission approved in Supabase', statusError)
}
async reject(id: string, reason: string): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
const { error } = await supabase
.from('catalog_submissions')
.update({ status: 'rejected', reviewer_id: user.id, review_reason: reason })
.eq('id', id)
if (error) console.error('Failed to reject submission in Supabase', error)
}
async getUsageImpact(entityType: CatalogSubmission['entityType'], entityId: string): Promise<UsageImpact> {
const { data, error } = await supabase.rpc('catalog_entity_usage_impact', {
p_entity_type: entityType,
p_entity_id: entityId,
})
if (error || !data || data.length === 0) {
if (error) console.error('Failed to load usage impact from Supabase', error)
return { diagramCount: 0, sample: [] }
}
const row = data[0] as { diagram_count: number; sample: UsageImpactSample[] }
return { diagramCount: row.diagram_count, sample: row.sample ?? [] }
}
async getSubmitterUsernames(submissionIds: string[]): Promise<Record<string, string>> {
if (submissionIds.length === 0) return {}
const { data, error } = await supabase.rpc('catalog_submission_submitters', { p_submission_ids: submissionIds })
if (error) {
console.error('Failed to load submitter usernames from Supabase', error)
return {}
}
const result: Record<string, string> = {}
for (const row of (data ?? []) as Array<{ submission_id: string; username: string }>) {
result[row.submission_id] = row.username
}
return result
}
}
+174
View File
@@ -0,0 +1,174 @@
import { FunctionsHttpError } from '@supabase/supabase-js'
import type {
AccountMigrationImpact,
AccountMigrationRecord,
AdminUserRepository,
AdminUserSummary,
UserRole,
} from './AdminUserRepository'
import { supabase } from './supabaseClient'
interface UserRow {
id: string
username: string
email: string
role: UserRole
can_post_announcements: boolean
banned_until: string | null
created_at: string
migrated_to_user_id: string | null
migrated_to_username: string | null
}
function toSummary(row: UserRow): AdminUserSummary {
return {
id: row.id,
username: row.username,
email: row.email,
role: row.role,
canPostAnnouncements: row.can_post_announcements,
bannedUntil: row.banned_until ?? undefined,
createdAt: row.created_at,
migratedToUserId: row.migrated_to_user_id ?? undefined,
migratedToUsername: row.migrated_to_username ?? undefined,
}
}
interface MigrationImpactRow {
diagram_count: number
private_entity_count: number
submission_count: number
}
function toImpact(row: MigrationImpactRow | undefined): AccountMigrationImpact {
return {
diagramCount: row?.diagram_count ?? 0,
privateEntityCount: row?.private_entity_count ?? 0,
submissionCount: row?.submission_count ?? 0,
}
}
type UserAction = 'ban' | 'unban' | 'delete'
/** Backs the app with list_users_for_admin (read), a direct profiles.role
* update, and the admin-user-action Edge Function (ban/unban/delete). */
export class SupabaseAdminUserRepository implements AdminUserRepository {
async listUsers(): Promise<AdminUserSummary[]> {
const { data, error } = await supabase.rpc('list_users_for_admin')
if (error) {
console.error('Failed to list users from Supabase', error)
return []
}
return ((data ?? []) as UserRow[]).map(toSummary)
}
async updateRole(userId: string, role: UserRole): Promise<void> {
const { error } = await supabase.from('profiles').update({ role }).eq('id', userId)
if (error) console.error('Failed to update user role in Supabase', error)
}
async updateCanPostAnnouncements(userId: string, canPostAnnouncements: boolean): Promise<void> {
const { error } = await supabase
.from('profiles')
.update({ can_post_announcements: canPostAnnouncements })
.eq('id', userId)
if (error) console.error('Failed to update can_post_announcements in Supabase', error)
}
private async invokeUserAction(action: UserAction, userId: string): Promise<void> {
const { error } = await supabase.functions.invoke('admin-user-action', { body: { action, userId } })
if (!error) return
// The function returns its actual reason (e.g. "Only a Super Admin can
// manage user accounts.") in the JSON body on a non-2xx response —
// surface that instead of supabase-js's generic "Edge Function
// returned a non-2xx status code" wrapper.
if (error instanceof FunctionsHttpError) {
const body = await error.context.json().catch(() => null)
const message = typeof body?.error === 'string' ? body.error : error.message
console.error(`Failed to ${action} user`, message)
throw new Error(message)
}
console.error(`Failed to ${action} user`, error)
throw error
}
banUser(userId: string): Promise<void> {
return this.invokeUserAction('ban', userId)
}
unbanUser(userId: string): Promise<void> {
return this.invokeUserAction('unban', userId)
}
deleteUser(userId: string): Promise<void> {
return this.invokeUserAction('delete', userId)
}
async previewAccountMigration(fromUserId: string, toUserId: string): Promise<AccountMigrationImpact> {
const { data, error } = await supabase.rpc('account_migration_preview', {
p_from_user_id: fromUserId,
p_to_user_id: toUserId,
})
if (error) console.error('Failed to preview account migration in Supabase', error)
return toImpact((data as MigrationImpactRow[] | null)?.[0])
}
async migrateAccount(fromUserId: string, toUserId: string, verificationNotes: string): Promise<AccountMigrationImpact> {
const { data, error } = await supabase.rpc('migrate_account_ownership', {
p_from_user_id: fromUserId,
p_to_user_id: toUserId,
p_verification_notes: verificationNotes,
})
if (error) {
console.error('Failed to migrate account in Supabase', error)
// Postgres raises the specific reason (already migrated, missing
// notes, self-migration) as the message — surface that verbatim
// rather than a generic fallback, wrapped in a real Error since the
// raw Postgrest error object isn't one (`instanceof Error` would
// otherwise fail for callers that check it).
throw new Error(error.message)
}
return toImpact((data as MigrationImpactRow[] | null)?.[0])
}
async listAccountMigrations(): Promise<AccountMigrationRecord[]> {
const { data, error } = await supabase
.from('account_migrations')
.select(
'id, from_user_id, to_user_id, from_username, to_username, performed_by, verification_notes, diagram_count, private_entity_count, submission_count, created_at',
)
.order('created_at', { ascending: false })
if (error) {
console.error('Failed to load account migrations from Supabase', error)
return []
}
return (
(data ?? []) as Array<{
id: string
from_user_id: string | null
to_user_id: string | null
from_username: string
to_username: string
performed_by: string | null
verification_notes: string
diagram_count: number
private_entity_count: number
submission_count: number
created_at: string
}>
).map((row) => ({
id: row.id,
fromUserId: row.from_user_id,
toUserId: row.to_user_id,
fromUsername: row.from_username,
toUsername: row.to_username,
performedBy: row.performed_by,
verificationNotes: row.verification_notes,
diagramCount: row.diagram_count,
privateEntityCount: row.private_entity_count,
submissionCount: row.submission_count,
createdAt: row.created_at,
}))
}
}
@@ -0,0 +1,74 @@
import type { Announcement, AnnouncementRepository } from './AnnouncementRepository'
import { supabase } from './supabaseClient'
interface AnnouncementRow {
id: string
message: string
created_at: string
}
/** Backs the app with the `announcements`/`announcement_dismissals` tables
* directly — no privileged function needed, since every read/write here is
* either publicly visible (the current announcement) or already scoped to
* your own rows (your dismissals) by RLS. */
export class SupabaseAnnouncementRepository implements AnnouncementRepository {
private async currentUserId(): Promise<string | null> {
const {
data: { user },
} = await supabase.auth.getUser()
return user?.id ?? null
}
async getCurrent(): Promise<Announcement | null> {
const { data: row, error } = await supabase
.from('announcements')
.select('id, message, created_at')
.is('retired_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
console.error('Failed to load the current announcement from Supabase', error)
return null
}
if (!row) return null
const current = row as AnnouncementRow
// RLS already scopes announcement_dismissals to your own rows, so
// finding any row at all for this announcement id means you dismissed it.
const { data: dismissal, error: dismissalError } = await supabase
.from('announcement_dismissals')
.select('announcement_id')
.eq('announcement_id', current.id)
.maybeSingle()
if (dismissalError) console.error('Failed to check announcement dismissal in Supabase', dismissalError)
return { id: current.id, message: current.message, createdAt: current.created_at, dismissed: !!dismissal }
}
async post(message: string): Promise<void> {
const createdBy = await this.currentUserId()
const { error } = await supabase.from('announcements').insert({ message, created_by: createdBy })
if (error) console.error('Failed to post announcement to Supabase', error)
}
async retireCurrent(): Promise<void> {
const { error } = await supabase
.from('announcements')
.update({ retired_at: new Date().toISOString() })
.is('retired_at', null)
if (error) console.error('Failed to retire the current announcement in Supabase', error)
}
async dismiss(announcementId: string): Promise<void> {
const userId = await this.currentUserId()
if (!userId) return
// Upsert-and-ignore rather than a plain insert — a double-click (or the
// banner re-rendering before its own dismissed state lands) would
// otherwise hit the (user_id, announcement_id) primary key and error.
const { error } = await supabase
.from('announcement_dismissals')
.upsert({ user_id: userId, announcement_id: announcementId }, { onConflict: 'user_id,announcement_id', ignoreDuplicates: true })
if (error) console.error('Failed to record announcement dismissal in Supabase', error)
}
}
+433
View File
@@ -0,0 +1,433 @@
import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, Port, PortType } from '../domain/types'
import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository'
import type { CatalogEntityType } from './SubmissionRepository'
import { supabase } from './supabaseClient'
interface DeviceCategoryRow {
id: string
name: string
is_public: boolean
}
interface ManufacturerRow {
id: string
name: string
is_public: boolean
}
interface PortTypeRow {
id: string
name: string
category: PortType['category']
family: string
compatible_family_ids: string[]
max_connections: number | null
is_public: boolean
}
interface CableTypeRow {
id: string
name: string
family: string
family2: string | null
unit: CableType['unit']
cost_per_unit: number | null
is_public: boolean
}
interface DeviceTemplateRow {
id: string
name: string
category_id: string
manufacturer_id: string | null
model: string | null
cost: number | null
is_public: boolean
}
interface DeviceTemplatePortRow {
id: string
device_template_id: string
name: string
direction: Port['direction']
port_type_id: string
sort_order: number
built_in_cable: boolean
}
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toManufacturer(row: ManufacturerRow): Manufacturer {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toPortType(row: PortTypeRow): PortType {
return {
id: row.id,
name: row.name,
category: row.category,
family: row.family,
compatibleFamilyIds: row.compatible_family_ids.length > 0 ? row.compatible_family_ids : undefined,
maxConnections: row.max_connections ?? undefined,
custom: !row.is_public,
}
}
function toCableType(row: CableTypeRow): CableType {
return {
id: row.id,
name: row.name,
family: row.family,
family2: row.family2 ?? undefined,
unit: row.unit,
costPerUnit: row.cost_per_unit ?? undefined,
custom: !row.is_public,
}
}
function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRow[]): DeviceTemplate {
return {
id: row.id,
name: row.name,
category: row.category_id,
manufacturerId: row.manufacturer_id ?? undefined,
model: row.model ?? undefined,
cost: row.cost ?? undefined,
ports: portRows
.filter((p) => p.device_template_id === row.id)
.sort((a, b) => a.sort_order - b.sort_order)
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id, builtInCable: p.built_in_cable })),
custom: !row.is_public,
}
}
/**
* Backs the app with the shared Supabase catalog tables instead of the
* per-diagram embedded custom-type arrays the app used before this pass.
* RLS scopes every select to what the current user can see (all public
* entries, plus their own private ones) — see the `*_select` policies in
* the init schema migration. Every write here creates or edits a private
* (is_public = false) row owned by the current user; there is no public
* write path yet (that's the submission workflow, organized-ideas.md §3).
*/
export class SupabaseCatalogRepository implements CatalogRepository {
async load(): Promise<Catalog> {
const [categories, manufacturers, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
supabase.from('device_categories').select('id, name, is_public').order('name'),
supabase.from('manufacturers').select('id, name, is_public').order('name'),
supabase
.from('port_types')
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
.order('name'),
supabase
.from('cable_types')
.select('id, name, family, family2, unit, cost_per_unit, is_public')
.order('name'),
supabase.from('device_templates').select('id, name, category_id, manufacturer_id, model, cost, is_public').order('name'),
supabase
.from('device_template_ports')
.select('id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable'),
])
for (const [label, result] of [
['device categories', categories],
['manufacturers', manufacturers],
['port types', portTypes],
['cable types', cableTypes],
['device templates', templates],
['device template ports', templatePorts],
] as const) {
if (result.error) console.error(`Failed to load ${label} from Supabase`, result.error)
}
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
return {
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
manufacturers: ((manufacturers.data ?? []) as ManufacturerRow[]).map(toManufacturer),
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
}
}
private async currentUserId(): Promise<string | null> {
const {
data: { user },
} = await supabase.auth.getUser()
return user?.id ?? null
}
private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise<DeviceCategoryDef> {
const id = uuid()
const { error } = await supabase.from('device_categories').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add device category to Supabase', error)
return { id, name, custom: !isPublic }
}
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, false, await this.currentUserId())
}
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, true, null)
}
async updateDeviceCategory(id: string, name: string): Promise<void> {
const { error } = await supabase.from('device_categories').update({ name }).eq('id', id)
if (error) console.error('Failed to update device category in Supabase', error)
}
async removeDeviceCategory(id: string): Promise<void> {
const { error } = await supabase.from('device_categories').delete().eq('id', id)
if (error) {
console.error('Failed to remove device category from Supabase', error)
throw error
}
}
private async insertManufacturer(name: string, isPublic: boolean, ownerId: string | null): Promise<Manufacturer> {
const id = uuid()
const { error } = await supabase.from('manufacturers').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add manufacturer to Supabase', error)
return { id, name, custom: !isPublic }
}
async addManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, false, await this.currentUserId())
}
adminAddManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, true, null)
}
async updateManufacturer(id: string, name: string): Promise<void> {
const { error } = await supabase.from('manufacturers').update({ name }).eq('id', id)
if (error) console.error('Failed to update manufacturer in Supabase', error)
}
async removeManufacturer(id: string): Promise<void> {
const { error } = await supabase.from('manufacturers').delete().eq('id', id)
if (error) {
console.error('Failed to remove manufacturer from Supabase', error)
throw error
}
}
private async insertPortType(
portType: Omit<PortType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<PortType> {
const id = uuid()
const { error } = await supabase
.from('port_types')
.insert({ id, ...portTypeToRow(portType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: !isPublic }
}
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, false, await this.currentUserId())
}
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, true, null)
}
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category = patch.category
if (patch.family !== undefined) payload.family = patch.family
if (patch.compatibleFamilyIds !== undefined) payload.compatible_family_ids = patch.compatibleFamilyIds
if (patch.maxConnections !== undefined) payload.max_connections = patch.maxConnections
const { error } = await supabase.from('port_types').update(payload).eq('id', id)
if (error) console.error('Failed to update port type in Supabase', error)
}
async removePortType(id: string): Promise<void> {
const { error } = await supabase.from('port_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove port type from Supabase', error)
throw error
}
}
private async insertCableType(
cableType: Omit<CableType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<CableType> {
const id = uuid()
const { error } = await supabase
.from('cable_types')
.insert({ id, ...cableTypeToRow(cableType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: !isPublic }
}
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, false, await this.currentUserId())
}
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, true, null)
}
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.family !== undefined) payload.family = patch.family
if (patch.family2 !== undefined) payload.family2 = patch.family2
if (patch.unit !== undefined) payload.unit = patch.unit
if (patch.costPerUnit !== undefined) payload.cost_per_unit = patch.costPerUnit
const { error } = await supabase.from('cable_types').update(payload).eq('id', id)
if (error) console.error('Failed to update cable type in Supabase', error)
}
async removeCableType(id: string): Promise<void> {
const { error } = await supabase.from('cable_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove cable type from Supabase', error)
throw error
}
}
private async insertDeviceTemplate(
template: Omit<DeviceTemplate, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<DeviceTemplate> {
const id = uuid()
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: isPublic, owner_id: ownerId })
if (error) {
console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: !isPublic }
}
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
if (ports.length > 0) {
const { error: portsError } = await supabase.from('device_template_ports').insert(
ports.map((port) => ({
id: port.id,
device_template_id: id,
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: port.sortOrder,
built_in_cable: port.builtInCable ?? false,
})),
)
if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
}
return { ...template, id, ports, custom: !isPublic }
}
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, false, await this.currentUserId())
}
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, true, null)
}
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category_id = patch.category
if (patch.manufacturerId !== undefined) payload.manufacturer_id = patch.manufacturerId
if (patch.model !== undefined) payload.model = patch.model
if (patch.cost !== undefined) payload.cost = patch.cost
if (Object.keys(payload).length > 0) {
const { error } = await supabase.from('device_templates').update(payload).eq('id', id)
if (error) console.error('Failed to update device template in Supabase', error)
}
if (patch.ports !== undefined) {
// Simplest correct approach: replace the whole port set rather than
// diffing. Templates are edited as a whole in the UI (DeviceTemplateEditor),
// so there's no risk of clobbering a concurrent partial edit.
const { error: deleteError } = await supabase.from('device_template_ports').delete().eq('device_template_id', id)
if (deleteError) console.error('Failed to clear device template ports in Supabase', deleteError)
if (patch.ports.length > 0) {
const { error: insertError } = await supabase.from('device_template_ports').insert(
patch.ports.map((port, index) => ({
id: uuid(),
device_template_id: id,
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
)
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
}
}
}
async removeDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) console.error('Failed to remove device template from Supabase', error)
}
// The update* methods above never check ownership themselves — RLS does,
// server-side, based on the caller's identity — so an Admin/Super-Admin
// direct edit of a public entry is *exactly* the same write, just
// permitted by a different branch of the same policy (`is_admin()`
// instead of "owns it and it's still private"). These are aliases, not
// separate logic, so the two paths can't drift apart.
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
return this.updatePortType(id, patch)
}
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
return this.updateCableType(id, patch)
}
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
return this.updateDeviceTemplate(id, patch)
}
adminUpdateDeviceCategory(id: string, name: string): Promise<void> {
return this.updateDeviceCategory(id, name)
}
adminUpdateManufacturer(id: string, name: string): Promise<void> {
return this.updateManufacturer(id, name)
}
async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> {
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id)
if (error) console.error('Failed to unpublish catalog entry in Supabase', error)
}
adminRemoveDeviceCategory(id: string): Promise<void> {
return this.removeDeviceCategory(id)
}
adminRemoveManufacturer(id: string): Promise<void> {
return this.removeManufacturer(id)
}
adminRemovePortType(id: string): Promise<void> {
return this.removePortType(id)
}
adminRemoveCableType(id: string): Promise<void> {
return this.removeCableType(id)
}
async adminRemoveDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) {
console.error('Failed to remove device template from Supabase', error)
throw error
}
}
}
@@ -0,0 +1,73 @@
import type {
CollaboratorPermission,
DiagramCollaborator,
DiagramCollaboratorRepository,
} from './DiagramCollaboratorRepository'
import { SelfCollaboratorError, UnknownUsernameError } from './DiagramCollaboratorRepository'
import { supabase } from './supabaseClient'
interface CollaboratorRow {
user_id: string
permission: CollaboratorPermission
}
export class SupabaseDiagramCollaboratorRepository implements DiagramCollaboratorRepository {
async list(diagramId: string): Promise<DiagramCollaborator[]> {
const [rowsResult, usernamesResult] = await Promise.all([
supabase.from('diagram_collaborators').select('user_id, permission').eq('diagram_id', diagramId),
supabase.rpc('diagram_collaborator_usernames', { p_diagram_id: diagramId }),
])
if (rowsResult.error) {
console.error('Failed to load collaborators from Supabase', rowsResult.error)
return []
}
if (usernamesResult.error) {
console.error('Failed to resolve collaborator usernames from Supabase', usernamesResult.error)
}
const usernameById = new Map<string, string>(
((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]),
)
return ((rowsResult.data ?? []) as CollaboratorRow[]).map((row) => ({
userId: row.user_id,
username: usernameById.get(row.user_id) ?? row.user_id,
permission: row.permission,
}))
}
async add(diagramId: string, username: string, permission: CollaboratorPermission): Promise<DiagramCollaborator> {
const { data: userId, error: lookupError } = await supabase.rpc('find_user_id_by_username', { p_username: username })
if (lookupError) {
console.error('Failed to resolve username to an id', lookupError)
throw lookupError
}
if (!userId) {
throw new UnknownUsernameError(`No account found for username "${username}".`)
}
const {
data: { user: currentUser },
} = await supabase.auth.getUser()
if (currentUser && userId === currentUser.id) {
throw new SelfCollaboratorError("You already have full access to your own diagram — you can't share it with yourself.")
}
const { error } = await supabase.from('diagram_collaborators').insert({ diagram_id: diagramId, user_id: userId, permission })
if (error) {
console.error('Failed to add collaborator in Supabase', error)
throw error
}
return { userId, username, permission }
}
async updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise<void> {
const { error } = await supabase
.from('diagram_collaborators')
.update({ permission })
.eq('diagram_id', diagramId)
.eq('user_id', userId)
if (error) console.error('Failed to update collaborator permission in Supabase', error)
}
async remove(diagramId: string, userId: string): Promise<void> {
const { error } = await supabase.from('diagram_collaborators').delete().eq('diagram_id', diagramId).eq('user_id', userId)
if (error) console.error('Failed to remove collaborator in Supabase', error)
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { Diagram } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
import { normalizeDiagram } from './exportImport'
import { supabase } from './supabaseClient'
/**
* Backs the app with the local (or eventually production) Supabase
* `diagrams` table instead of localStorage. The whole `Diagram` domain
* object is stored as-is in the `data` JSONB column — see the schema
* migration's header comment for why diagrams are a document, not
* normalized relational rows.
*
* RLS already scopes every query here to what the signed-in user can see
* (their own diagrams, plus anything shared with them per organized-ideas.md
* §8) — no need to filter by owner_id client-side.
*/
export class SupabaseDiagramRepository implements DiagramRepository {
async list(): Promise<DiagramSummary[]> {
const { data, error } = await supabase.from('diagrams').select('id, name, updated_at').order('updated_at', {
ascending: false,
})
if (error) {
console.error('Failed to list diagrams from Supabase', error)
return []
}
return (data ?? []).map((row) => ({ id: row.id, name: row.name, updatedAt: row.updated_at }))
}
async loadById(id: string): Promise<Diagram | null> {
const { data: row, error } = await supabase.from('diagrams').select('data').eq('id', id).maybeSingle()
if (error) {
console.error('Failed to load diagram from Supabase', error)
return null
}
if (!row) return null
return normalizeDiagram(row.data as Diagram)
}
async save(diagram: Diagram): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
console.error('Cannot save diagram: not signed in')
return
}
// Deliberately update-or-insert rather than a blind upsert: an upsert
// would resend owner_id on every save and silently reassign ownership
// to whoever saves last — once diagram sharing (§8) is in play, an
// edit-collaborator saving changes must never be able to overwrite who
// owns the diagram.
const { data: existing } = await supabase.from('diagrams').select('id, data').eq('id', diagram.id).maybeSingle()
if (existing) {
await this.maybeWriteSnapshot(diagram.id, existing.data as Diagram, user.id)
}
const payload = { name: diagram.name, data: diagram, updated_at: new Date().toISOString() }
const { error } = existing
? await supabase.from('diagrams').update(payload).eq('id', diagram.id)
: await supabase.from('diagrams').insert({ id: diagram.id, owner_id: user.id, ...payload })
if (error) {
console.error('Failed to save diagram to Supabase', error)
}
}
// Checkpoints the *pre-save* state periodically (§8's undo/recovery net)
// rather than on every autosave — autosave fires 400ms after each edit,
// which would otherwise flood the snapshot table with one row per
// keystroke-adjacent change instead of meaningful recovery points.
private static readonly SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000
private async maybeWriteSnapshot(diagramId: string, previousData: Diagram, userId: string): Promise<void> {
const { data: lastSnapshot } = await supabase
.from('diagram_snapshots')
.select('created_at')
.eq('diagram_id', diagramId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
const dueForSnapshot =
!lastSnapshot || Date.now() - new Date(lastSnapshot.created_at).getTime() >= SupabaseDiagramRepository.SNAPSHOT_INTERVAL_MS
if (!dueForSnapshot) return
const { error } = await supabase
.from('diagram_snapshots')
.insert({ diagram_id: diagramId, data: previousData, saved_by: userId })
if (error) console.error('Failed to write diagram snapshot', error)
}
async deleteById(id: string): Promise<void> {
const { error } = await supabase.from('diagrams').delete().eq('id', id)
if (error) {
console.error('Failed to delete diagram from Supabase', error)
}
}
async getAccess(id: string): Promise<DiagramAccess | null> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return null
const { data: diagram, error } = await supabase.from('diagrams').select('owner_id').eq('id', id).maybeSingle()
if (error) console.error('Failed to load diagram access info from Supabase', error)
if (!diagram) return null
if (diagram.owner_id === user.id) return { ownerId: diagram.owner_id, myPermission: 'owner' }
const { data: collaborator } = await supabase
.from('diagram_collaborators')
.select('permission')
.eq('diagram_id', id)
.eq('user_id', user.id)
.maybeSingle()
if (collaborator) return { ownerId: diagram.owner_id, myPermission: collaborator.permission }
// Visible without being the owner or a listed collaborator only happens
// for a Super Admin (diagrams_select's override) — diagrams_update
// grants them unconditional edit rights too, so 'edit' is the accurate
// capability to report, not a misleading read-only default.
return { ownerId: diagram.owner_id, myPermission: 'edit' }
}
}
@@ -0,0 +1,73 @@
import type { Diagram } from '../domain/types'
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from './DiagramSnapshotRepository'
import { normalizeDiagram } from './exportImport'
import { supabase } from './supabaseClient'
interface SnapshotRow {
id: string
created_at: string
saved_by: string | null
}
export class SupabaseDiagramSnapshotRepository implements DiagramSnapshotRepository {
async list(diagramId: string): Promise<DiagramSnapshotSummary[]> {
const [rowsResult, usernamesResult] = await Promise.all([
supabase
.from('diagram_snapshots')
.select('id, created_at, saved_by')
.eq('diagram_id', diagramId)
.order('created_at', { ascending: false }),
supabase.rpc('diagram_snapshot_saved_by_usernames', { p_diagram_id: diagramId }),
])
if (rowsResult.error) {
console.error('Failed to load diagram snapshots from Supabase', rowsResult.error)
return []
}
if (usernamesResult.error) {
console.error('Failed to resolve snapshot saver usernames from Supabase', usernamesResult.error)
}
const usernameById = new Map<string, string>(
((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]),
)
return ((rowsResult.data ?? []) as SnapshotRow[]).map((row) => ({
id: row.id,
createdAt: row.created_at,
savedByUsername: row.saved_by ? usernameById.get(row.saved_by) : undefined,
}))
}
async restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Diagram | null> {
const { data: snapshot, error } = await supabase.from('diagram_snapshots').select('data').eq('id', snapshotId).maybeSingle()
if (error) console.error('Failed to load snapshot from Supabase', error)
if (!snapshot) return null
// Snapshot the pre-restore state first, so restoring is itself
// recoverable — the same reasoning as any other checkpoint. Skipped
// when the caller already knows the live state is an unmodified copy
// of an existing snapshot (see DiagramSnapshotRepository's doc
// comment) — writing it again would just be a duplicate.
if (writeSafetySnapshot) {
const [{ data: current }, { data: userData }] = await Promise.all([
supabase.from('diagrams').select('data').eq('id', diagramId).maybeSingle(),
supabase.auth.getUser(),
])
if (current) {
const { error: snapshotError } = await supabase
.from('diagram_snapshots')
.insert({ diagram_id: diagramId, data: current.data, saved_by: userData.user?.id ?? null })
if (snapshotError) console.error('Failed to snapshot pre-restore state', snapshotError)
}
}
const restored = normalizeDiagram(snapshot.data as Diagram)
const { error: updateError } = await supabase
.from('diagrams')
.update({ data: restored, name: restored.name, updated_at: new Date().toISOString() })
.eq('id', diagramId)
if (updateError) {
console.error('Failed to restore snapshot in Supabase', updateError)
return null
}
return restored
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from './SubmissionRepository'
import { supabase } from './supabaseClient'
import { toSubmission, type SubmissionRow } from './submissionRowMapping'
/** Backs the app with the `catalog_submissions` table. RLS already scopes
* every query/write here to what a regular (non-Admin) user should be able
* to see and do with their own submissions — see that table's policies in
* the init schema migration. */
export class SupabaseSubmissionRepository implements SubmissionRepository {
async listMine(): Promise<CatalogSubmission[]> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return []
const { data, error } = await supabase
.from('catalog_submissions')
.select('*')
.eq('submitter_id', user.id)
.order('created_at', { ascending: false })
if (error) {
console.error('Failed to load submissions from Supabase', error)
return []
}
return ((data ?? []) as SubmissionRow[]).map(toSubmission)
}
async submit(entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>): Promise<CatalogSubmission> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
const { data, error } = await supabase
.from('catalog_submissions')
.insert({ entity_type: entityType, entity_id: entityId, proposed_data: proposedData, submitter_id: user.id, status: 'pending' })
.select('*')
.single()
if (error || !data) {
console.error('Failed to submit to Supabase', error)
throw error ?? new Error('Submission failed.')
}
return toSubmission(data as SubmissionRow)
}
async resubmit(id: string, proposedData: Record<string, unknown>): Promise<void> {
// Clears reviewer_id/review_reason too — a resubmission starts a fresh
// review cycle, so the old rejection reason shouldn't linger as if it
// still applied to the (now revised) proposed_data.
const { error } = await supabase
.from('catalog_submissions')
.update({ proposed_data: proposedData, status: 'pending', reviewer_id: null, review_reason: null })
.eq('id', id)
if (error) console.error('Failed to resubmit to Supabase', error)
}
async updateProposedData(id: string, proposedData: Record<string, unknown>): Promise<void> {
const { error } = await supabase.from('catalog_submissions').update({ proposed_data: proposedData }).eq('id', id)
if (error) console.error('Failed to sync submission data in Supabase', error)
}
async withdraw(id: string): Promise<void> {
const { error } = await supabase.from('catalog_submissions').delete().eq('id', id)
if (error) console.error('Failed to withdraw submission from Supabase', error)
}
}
+126
View File
@@ -0,0 +1,126 @@
import type { CatalogEntityType } from './SubmissionRepository'
import type { CableType, DeviceTemplate, PortType } from '../domain/types'
/**
* Maps domain objects to the shape of their Supabase table row (snake_case
* columns, no id/is_public/owner_id). Shared by two call sites that both
* need a row-shaped payload: SupabaseCatalogRepository's inserts, and
* catalog submissions' `proposed_data` (see SubmissionRepository) — a
* submission's proposed_data is deliberately row-shaped so approving it is
* just writing that object onto the row at `entity_id`, not a translation
* step an Admin's approval action would otherwise have to duplicate.
*/
/** Which table backs each catalog entity type — shared by
* SupabaseAdminSubmissionRepository (approving into the right table) and
* SupabaseCatalogRepository (direct Admin unpublish). */
export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
device_template: 'device_templates',
port_type: 'port_types',
cable_type: 'cable_types',
device_category: 'device_categories',
manufacturer: 'manufacturers',
}
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: portType.name,
category: portType.category,
family: portType.family,
compatible_family_ids: portType.compatibleFamilyIds ?? [],
max_connections: portType.maxConnections ?? null,
}
}
export function cableTypeToRow(cableType: Omit<CableType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: cableType.name,
family: cableType.family,
family2: cableType.family2 ?? null,
unit: cableType.unit,
cost_per_unit: cableType.costPerUnit ?? null,
}
}
export function deviceCategoryToRow(name: string): Record<string, unknown> {
return { name }
}
export function manufacturerToRow(name: string): Record<string, unknown> {
return { name }
}
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
return {
name: template.name,
category_id: template.category,
manufacturer_id: template.manufacturerId ?? null,
model: template.model ?? null,
cost: template.cost ?? null,
ports: template.ports.map((port, index) => ({
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
}
}
/**
* The reverse direction: turns a `catalog_submissions.proposed_data` row
* back into domain-shaped fields, so a rejected/pending submission can be
* reopened in the same form component that created it (see MySubmissionsModal)
* instead of needing a bespoke "edit a raw JSON blob" UI. Safe because
* proposed_data is always a *complete* row projection (every field of the
* type, per the `*ToRow` functions above), never a partial patch.
*/
export function rowToPortTypeFields(row: Record<string, unknown>): Omit<PortType, 'id' | 'custom'> {
const compatibleFamilyIds = row.compatible_family_ids as string[] | undefined
return {
name: row.name as string,
category: row.category as PortType['category'],
family: row.family as string,
compatibleFamilyIds: compatibleFamilyIds && compatibleFamilyIds.length > 0 ? compatibleFamilyIds : undefined,
maxConnections: (row.max_connections as number | null) ?? undefined,
}
}
export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableType, 'id' | 'custom'> {
return {
name: row.name as string,
family: row.family as string,
family2: (row.family2 as string | null) ?? undefined,
unit: row.unit as CableType['unit'],
costPerUnit: (row.cost_per_unit as number | null) ?? undefined,
}
}
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
const ports =
(row.ports as Array<{
name: string
direction: DeviceTemplate['ports'][number]['direction']
port_type_id: string
built_in_cable?: boolean
}>) ?? []
return {
name: row.name as string,
category: row.category_id as string,
manufacturerId: (row.manufacturer_id as string | null) ?? undefined,
model: (row.model as string | null) ?? undefined,
cost: (row.cost as number | null) ?? undefined,
ports: ports.map((port, index) => ({
id: `draft-port-${index}`,
name: port.name,
direction: port.direction,
portTypeId: port.port_type_id,
builtInCable: port.built_in_cable ?? false,
})),
}
}
export function rowToDeviceCategoryName(row: Record<string, unknown>): string {
return row.name as string
}
+30 -31
View File
@@ -1,15 +1,15 @@
import type { Project } from '../domain/types' import type { Diagram } from '../domain/types'
export function projectToJson(project: Project): string { export function diagramToJson(diagram: Diagram): string {
return JSON.stringify(project, null, 2) return JSON.stringify(diagram, null, 2)
} }
export function downloadProjectFile(project: Project): void { export function downloadDiagramFile(diagram: Diagram): void {
const json = projectToJson(project) const json = diagramToJson(diagram)
const blob = new Blob([json], { type: 'application/json' }) const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
const safeName = project.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'project' const safeName = diagram.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'diagram'
a.href = url a.href = url
a.download = `${safeName}.avplan.json` a.download = `${safeName}.avplan.json`
document.body.appendChild(a) document.body.appendChild(a)
@@ -18,47 +18,46 @@ export function downloadProjectFile(project: Project): void {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
class ProjectImportError extends Error {} class DiagramImportError extends Error {}
/** Minimal structural check — enough to catch "wrong file" without pulling /** Minimal structural check — enough to catch "wrong file" without pulling
* in a schema validation library for a single call site. */ * in a schema validation library for a single call site. */
function assertLooksLikeProject(value: unknown): asserts value is Project { function assertLooksLikeDiagram(value: unknown): asserts value is Diagram {
if (typeof value !== 'object' || value === null) { if (typeof value !== 'object' || value === null) {
throw new ProjectImportError('File does not contain a JSON object.') throw new DiagramImportError('File does not contain a JSON object.')
} }
const p = value as Partial<Project> const d = value as Partial<Diagram>
if (typeof p.id !== 'string' || typeof p.name !== 'string') { if (typeof d.id !== 'string' || typeof d.name !== 'string') {
throw new ProjectImportError('File is missing required project fields.') throw new DiagramImportError('File is missing required diagram fields.')
} }
if (!Array.isArray(p.devices) || !Array.isArray(p.connections)) { if (!Array.isArray(d.devices) || !Array.isArray(d.connections)) {
throw new ProjectImportError('File is missing devices/connections arrays.') throw new DiagramImportError('File is missing devices/connections arrays.')
} }
} }
export async function readProjectFile(file: File): Promise<Project> { export async function readDiagramFile(file: File): Promise<Diagram> {
const text = await file.text() const text = await file.text()
let parsed: unknown let parsed: unknown
try { try {
parsed = JSON.parse(text) parsed = JSON.parse(text)
} catch { } catch {
throw new ProjectImportError('File is not valid JSON.') throw new DiagramImportError('File is not valid JSON.')
} }
assertLooksLikeProject(parsed) assertLooksLikeDiagram(parsed)
return normalizeProject(parsed) return normalizeDiagram(parsed)
} }
/** Fills in collections added after a project may have been saved/exported, /** Strips any legacy per-diagram catalog fields (customDeviceTemplates,
* so older data (from localStorage or an older export file) doesn't crash * customPortTypes, etc.) that might still be present in an older export
* code that expects these arrays to always exist. */ * file or a localStorage diagram saved before the catalog moved to
export function normalizeProject(project: Project): Project { * Supabase (see state/catalogStore.ts) — that data now lives in the shared
return { * catalog, not the diagram, so it's just discarded here rather than
...project, * crashing on an unexpected shape. */
customDeviceTemplates: project.customDeviceTemplates ?? [], export function normalizeDiagram(diagram: Diagram): Diagram {
customPortTypes: project.customPortTypes ?? [], const { id, name, createdAt, updatedAt, devices, connections, bundles } = diagram
customCableTypes: project.customCableTypes ?? [], // Defaults to [] for any diagram saved before bundles existed — same
customDeviceCategories: project.customDeviceCategories ?? [], // "don't crash on an older shape" contract as the rest of this function.
hiddenBuiltInDeviceTemplateIds: project.hiddenBuiltInDeviceTemplateIds ?? [], return { id, name, createdAt, updatedAt, devices, connections, bundles: Array.isArray(bundles) ? bundles : [] }
}
} }
export { ProjectImportError } export { DiagramImportError }
+32
View File
@@ -0,0 +1,32 @@
import type { CatalogEntityType, CatalogSubmission } from './SubmissionRepository'
/** Shared by SupabaseSubmissionRepository (submitter's-eye view) and
* SupabaseAdminSubmissionRepository (Admin's-eye view) — both read/write
* the same `catalog_submissions` table shape. */
export interface SubmissionRow {
id: string
entity_type: CatalogEntityType
entity_id: string
proposed_data: Record<string, unknown>
submitter_id: string
status: CatalogSubmission['status']
reviewer_id: string | null
review_reason: string | null
created_at: string
updated_at: string
}
export function toSubmission(row: SubmissionRow): CatalogSubmission {
return {
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
proposedData: row.proposed_data,
submitterId: row.submitter_id,
status: row.status,
reviewerId: row.reviewer_id ?? undefined,
reviewReason: row.review_reason ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { createClient } from '@supabase/supabase-js'
const rawUrl = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
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).',
)
}
/** 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)
+179 -17
View File
@@ -1,5 +1,17 @@
import { allCableTypes } from './project' import { allCableTypes, manufacturerName } from './diagram'
import type { Device, Project } from './types' import type { CableType, Catalog, Device, Diagram } from './types'
/** One length within a cable type's group — e.g. "3 × 10ft". `distance`
* is undefined for the bucket of connections that have no length entered
* (kept separate rather than folded into a 0ft bucket, same reasoning as
* connectionsMissingLength on the parent line). */
export interface BomCableLengthLine {
distance: number | undefined
count: number
totalLength: number
totalCost: number
connectionsMissingCost: number
}
export interface BomCableLine { export interface BomCableLine {
cableTypeId: string cableTypeId: string
@@ -9,6 +21,15 @@ export interface BomCableLine {
/** Sum of user-entered distances; connections with no distance are excluded. */ /** Sum of user-entered distances; connections with no distance are excluded. */
totalLength: number totalLength: number
connectionsMissingLength: number connectionsMissingLength: number
/** Sum of each connection's cost (its own override, or `costPerUnit *
* distance` when both are known); connections with no computable cost are
* excluded rather than counted as zero. */
totalCost: number
connectionsMissingCost: number
/** This type's connections grouped by length, most common use case for
* "how many N-foot cables do I need to buy" — see organized-ideas.md §7.
* Sorted shortest first, with the no-length-entered bucket (if any) last. */
lengths: BomCableLengthLine[]
} }
export interface BomDeviceLine { export interface BomDeviceLine {
@@ -18,59 +39,200 @@ export interface BomDeviceLine {
manufacturer?: string manufacturer?: string
model?: string model?: string
count: number count: number
/** Sum of `device.cost` across instances; devices with no cost set are
* excluded rather than counted as zero. */
totalCost: number
devicesMissingCost: number
} }
export interface Bom { export interface Bom {
cables: BomCableLine[] cables: BomCableLine[]
devices: BomDeviceLine[] devices: BomDeviceLine[]
/** Sum of all cable line totals (missing-cost connections already excluded). */
totalCableCost: number
/** Sum of all device line totals (missing-cost devices already excluded). */
totalDeviceCost: number
/** `totalCableCost + totalDeviceCost`. Missing costs are excluded, not
* treated as zero, so this is a lower bound whenever anything is flagged
* as missing below. */
grandTotalCost: number
} }
function deviceGroupKey(device: Device): string { function deviceGroupKey(device: Device): string {
return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}` return device.templateId ?? `${device.category}:${device.manufacturerId ?? ''}:${device.model ?? device.name}`
} }
export function computeBom(project: Project): Bom { /** A connection's (or a bundle's — same shape, just its own distance/cost
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct])) * standing in for a member's) cost for BOM purposes: its own override if
* set, else `costPerUnit * distance` if both the cable type's rate and the
* distance are known. Undefined means "not computable" — callers must
* exclude it from totals rather than treating it as zero. */
function runCost(run: { cost?: number; distance?: number }, cableType: CableType | undefined): number | undefined {
if (typeof run.cost === 'number' && !Number.isNaN(run.cost)) {
return run.cost
}
if (
cableType &&
typeof cableType.costPerUnit === 'number' &&
!Number.isNaN(cableType.costPerUnit) &&
typeof run.distance === 'number' &&
!Number.isNaN(run.distance)
) {
return cableType.costPerUnit * run.distance
}
return undefined
}
const cableLines = new Map<string, BomCableLine>() /** Every port id, across all devices, with a built-in/captive cable — a
for (const connection of project.connections) { * connection touching either end of one doesn't need a cable purchased
const cableType = cableTypes.get(connection.cableTypeId) * (organized-ideas.md-adjacent decision: the cable's already paid for as
* part of the device, like a keyboard's USB cable or a budget AVR's power
* cord). Built once per computeBom call rather than searched per
* connection. */
function builtInPortIds(devices: Device[]): Set<string> {
const ids = new Set<string>()
for (const device of devices) {
for (const port of device.ports) {
if (port.builtInCable) ids.add(port.id)
}
}
return ids
}
/** Folds one purchasable unit — a plain connection, or a whole bundle
* standing in for its members — into the running cableLines/lengthLines
* maps. Shared by both so a bundle is aggregated (and combines with other
* same-type/same-length units, bundled or not) exactly like a connection
* would be, just contributing one unit regardless of how many legs it has. */
function addCableUnit(
cableLines: Map<string, BomCableLine>,
lengthLines: Map<string, Map<number | undefined, BomCableLengthLine>>,
cableTypes: Map<string, CableType>,
run: { cableTypeId: string; distance?: number; cost?: number },
): void {
const cableType = cableTypes.get(run.cableTypeId)
const name = cableType?.name ?? 'Unknown Cable' const name = cableType?.name ?? 'Unknown Cable'
const unit = cableType?.unit ?? 'ft' const unit = cableType?.unit ?? 'ft'
const existing = cableLines.get(connection.cableTypeId) ?? { const existing = cableLines.get(run.cableTypeId) ?? {
cableTypeId: connection.cableTypeId, cableTypeId: run.cableTypeId,
cableTypeName: name, cableTypeName: name,
unit, unit,
count: 0, count: 0,
totalLength: 0, totalLength: 0,
connectionsMissingLength: 0, connectionsMissingLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
lengths: [],
} }
existing.count += 1 existing.count += 1
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) { const distance = typeof run.distance === 'number' && !Number.isNaN(run.distance) ? run.distance : undefined
existing.totalLength += connection.distance if (distance !== undefined) {
existing.totalLength += distance
} else { } else {
existing.connectionsMissingLength += 1 existing.connectionsMissingLength += 1
} }
cableLines.set(connection.cableTypeId, existing) const cost = runCost(run, cableType)
if (cost !== undefined) {
existing.totalCost += cost
} else {
existing.connectionsMissingCost += 1
}
cableLines.set(run.cableTypeId, existing)
const byLength = lengthLines.get(run.cableTypeId) ?? new Map<number | undefined, BomCableLengthLine>()
const lengthLine = byLength.get(distance) ?? {
distance,
count: 0,
totalLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
}
lengthLine.count += 1
if (distance !== undefined) lengthLine.totalLength += distance
if (cost !== undefined) {
lengthLine.totalCost += cost
} else {
lengthLine.connectionsMissingCost += 1
}
byLength.set(distance, lengthLine)
lengthLines.set(run.cableTypeId, byLength)
}
export function computeBom(diagram: Diagram, catalog: Catalog): Bom {
const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct]))
const builtIn = builtInPortIds(diagram.devices)
const cableLines = new Map<string, BomCableLine>()
// Nested by cableTypeId, then by distance (undefined key for "no length
// entered") — a separate map from cableLines since a Map key can't itself
// be a nested Map, and it's convenient to keep the length-line objects
// addressable by (cableTypeId, distance) while building them up.
const lengthLines = new Map<string, Map<number | undefined, BomCableLengthLine>>()
for (const connection of diagram.connections) {
// A built-in cable is already paid for as part of the device — no line
// for it here, not even a "missing cost" note; the canvas is where this
// connection's built-in-ness is visible, not the shopping list.
if (builtIn.has(connection.sourcePortId) || builtIn.has(connection.targetPortId)) continue
// Represented by its bundle below instead — a bundled leg never gets
// its own BOM line.
if (connection.bundleId) continue
addCableUnit(cableLines, lengthLines, cableTypes, connection)
}
for (const bundle of diagram.bundles) {
// A bundle with no remaining members (every leg was rewired or deleted)
// shouldn't normally exist — removeConnection/removeConnectionFromBundle
// prune it — but skip defensively rather than phantom-charging for it.
const hasMember = diagram.connections.some((c) => c.bundleId === bundle.id)
if (!hasMember) continue
addCableUnit(cableLines, lengthLines, cableTypes, bundle)
}
for (const [cableTypeId, line] of cableLines) {
const byLength = [...(lengthLines.get(cableTypeId)?.values() ?? [])]
// Shortest first; the no-length-entered bucket (distance undefined)
// always sorts last rather than mixing in as "smallest".
byLength.sort((a, b) => {
if (a.distance === undefined) return 1
if (b.distance === undefined) return -1
return a.distance - b.distance
})
line.lengths = byLength
} }
const deviceLines = new Map<string, BomDeviceLine>() const deviceLines = new Map<string, BomDeviceLine>()
for (const device of project.devices) { for (const device of diagram.devices) {
const key = deviceGroupKey(device) const key = deviceGroupKey(device)
const existing = deviceLines.get(key) ?? { const existing = deviceLines.get(key) ?? {
key, key,
name: device.name, name: device.name,
category: device.category, category: device.category,
manufacturer: device.manufacturer, manufacturer: manufacturerName(catalog, device.manufacturerId),
model: device.model, model: device.model,
count: 0, count: 0,
totalCost: 0,
devicesMissingCost: 0,
} }
existing.count += 1 existing.count += 1
if (typeof device.cost === 'number' && !Number.isNaN(device.cost)) {
existing.totalCost += device.cost
} else {
existing.devicesMissingCost += 1
}
deviceLines.set(key, existing) deviceLines.set(key, existing)
} }
const cables = [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName))
const devices = [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name))
const totalCableCost = cables.reduce((sum, line) => sum + line.totalCost, 0)
const totalDeviceCost = devices.reduce((sum, line) => sum + line.totalCost, 0)
return { return {
cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)), cables,
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)), devices,
totalCableCost,
totalDeviceCost,
grandTotalCost: totalCableCost + totalDeviceCost,
} }
} }
+10 -9
View File
@@ -1,5 +1,5 @@
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project' import { acceptedFamilies, getPortType, hasBridgingCableType } from './diagram'
import type { Connection, Device, PortType, Project } from './types' import type { Catalog, Connection, Device, Diagram, PortType } from './types'
/** /**
* Two ports connect if the sets of families they each accept (their own * Two ports connect if the sets of families they each accept (their own
@@ -36,9 +36,10 @@ export interface ConnectionValidation {
* server-side later since it only depends on plain domain data. * server-side later since it only depends on plain domain data.
*/ */
export function validateConnection( export function validateConnection(
project: Project, diagram: Diagram,
catalog: Catalog,
candidate: ConnectionCandidate, candidate: ConnectionCandidate,
existingConnections: Connection[] = project.connections, existingConnections: Connection[] = diagram.connections,
): ConnectionValidation { ): ConnectionValidation {
const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate
@@ -46,8 +47,8 @@ export function validateConnection(
return { valid: false, reason: 'A port cannot connect to itself.' } return { valid: false, reason: 'A port cannot connect to itself.' }
} }
const sourceDevice = project.devices.find((d) => d.id === sourceDeviceId) const sourceDevice = diagram.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === targetDeviceId) const targetDevice = diagram.devices.find((d) => d.id === targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId) const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId) const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
@@ -62,15 +63,15 @@ export function validateConnection(
return { valid: false, reason: 'Cannot connect two output ports.' } return { valid: false, reason: 'Cannot connect two output ports.' }
} }
const sourcePortType = getPortType(project, sourcePort.portTypeId) const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId) const targetPortType = getPortType(catalog, targetPort.portTypeId)
if (!sourcePortType || !targetPortType) { if (!sourcePortType || !targetPortType) {
return { valid: false, reason: 'Unknown port type.' } return { valid: false, reason: 'Unknown port type.' }
} }
// Directly compatible port types, or bridgeable by a specific adapter // Directly compatible port types, or bridgeable by a specific adapter
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the // cable (different connectors on each end, e.g. XLR-to-TRS) even when the
// port types themselves declare no general compatibility. // port types themselves declare no general compatibility.
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(project, sourcePortType, targetPortType)) { if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(catalog, sourcePortType, targetPortType)) {
return { return {
valid: false, valid: false,
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`, reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
+2 -2
View File
@@ -1,7 +1,7 @@
import type { PortCategory, PortDirection } from './types' import type { PortCategory, PortDirection } from './types'
// Device categories are no longer a fixed list — see `domain/project.ts`'s // Device categories are no longer a fixed list — see `domain/diagram.ts`'s
// `allDeviceCategories` (built-ins + project-custom, merged). // `allDeviceCategories` (public catalog + your own private ones, merged).
export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [ export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [
{ value: 'input', label: 'Input' }, { value: 'input', label: 'Input' },
+58 -65
View File
@@ -1,58 +1,70 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { BUILT_IN_CABLE_TYPES, BUILT_IN_DEVICE_CATEGORIES, BUILT_IN_DEVICE_TEMPLATES, BUILT_IN_PORT_TYPES } from './library' import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
import type { CableType, Device, DeviceCategoryDef, DeviceTemplate, Port, PortType, Project } from './types'
/** Merges built-in + project-custom entries. Custom entries with a matching export function allPortTypes(catalog: Catalog): PortType[] {
* id win, so a project could in principle override a built-in (not exposed return catalog.portTypes
* in the UI today, but keeps the merge rule simple and predictable). */
function mergeById<T extends { id: string }>(builtIns: T[], custom: T[]): T[] {
const byId = new Map(builtIns.map((item) => [item.id, item]))
for (const item of custom) byId.set(item.id, item)
return [...byId.values()]
} }
export function allPortTypes(project: Project): PortType[] { export function allCableTypes(catalog: Catalog): CableType[] {
return mergeById(BUILT_IN_PORT_TYPES, project.customPortTypes) return catalog.cableTypes
} }
export function allCableTypes(project: Project): CableType[] { /** `hiddenPublicIds` is a per-user UI preference (see state/catalogStore.ts),
return mergeById(BUILT_IN_CABLE_TYPES, project.customCableTypes) * not part of the catalog data itself — you can hide any public template
* from your own palette without affecting anyone else or the shared entry. */
export function allDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string> = new Set()): DeviceTemplate[] {
return catalog.deviceTemplates.filter((t) => t.custom || !hiddenPublicIds.has(t.id))
} }
export function allDeviceTemplates(project: Project): DeviceTemplate[] { /** Public templates the user has hidden, for a "restore" list. */
const merged = mergeById(BUILT_IN_DEVICE_TEMPLATES, project.customDeviceTemplates) export function hiddenDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string>): DeviceTemplate[] {
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds) return catalog.deviceTemplates.filter((t) => !t.custom && hiddenPublicIds.has(t.id))
return merged.filter((t) => !(t.builtIn && hidden.has(t.id)))
} }
/** Built-in templates the user has hidden, for a "restore" list — deleting a export function allDeviceCategories(catalog: Catalog): DeviceCategoryDef[] {
* custom template is permanent, but hiding a built-in one isn't, since the return catalog.deviceCategories
* built-in still exists in code and can always be shown again. */
export function hiddenBuiltInDeviceTemplates(project: Project): DeviceTemplate[] {
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
return BUILT_IN_DEVICE_TEMPLATES.filter((t) => hidden.has(t.id))
} }
export function allDeviceCategories(project: Project): DeviceCategoryDef[] { export function getDeviceCategory(catalog: Catalog, categoryId: string): DeviceCategoryDef | undefined {
return mergeById(BUILT_IN_DEVICE_CATEGORIES, project.customDeviceCategories) return allDeviceCategories(catalog).find((c) => c.id === categoryId)
}
export function getDeviceCategory(project: Project, categoryId: string): DeviceCategoryDef | undefined {
return allDeviceCategories(project).find((c) => c.id === categoryId)
} }
/** Friendly label for a category id, falling back to the raw id if it's /** Friendly label for a category id, falling back to the raw id if it's
* somehow unknown (e.g. data from an older export) rather than crashing. */ * somehow unknown (e.g. data from an older export) rather than crashing. */
export function deviceCategoryName(project: Project, categoryId: string): string { export function deviceCategoryName(catalog: Catalog, categoryId: string): string {
return getDeviceCategory(project, categoryId)?.name ?? categoryId return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
} }
export function getPortType(project: Project, portTypeId: string): PortType | undefined { export function allManufacturers(catalog: Catalog): Manufacturer[] {
return allPortTypes(project).find((pt) => pt.id === portTypeId) return catalog.manufacturers
} }
export function getCableType(project: Project, cableTypeId: string): CableType | undefined { export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
return allCableTypes(project).find((ct) => ct.id === cableTypeId) return allManufacturers(catalog).find((m) => m.id === manufacturerId)
}
/** Friendly name for a manufacturer id — unlike `deviceCategoryName`,
* `manufacturerId` is optional (not every device has a known manufacturer),
* so this returns undefined rather than a fallback string when it's unset
* or, for older data, references a manufacturer that no longer exists. */
export function manufacturerName(catalog: Catalog, manufacturerId: string | undefined): string | undefined {
return manufacturerId ? getManufacturer(catalog, manufacturerId)?.name : undefined
}
export function getPortType(catalog: Catalog, portTypeId: string): PortType | undefined {
return allPortTypes(catalog).find((pt) => pt.id === portTypeId)
}
export function getCableType(catalog: Catalog, cableTypeId: string): CableType | undefined {
return allCableTypes(catalog).find((ct) => ct.id === cableTypeId)
}
/** Best-effort friendly label for a raw family string: the name of whichever
* port type owns that family, or the raw string itself if none matches
* (e.g. a family that only ever existed as hand-typed text, from before
* FamilySelect started sourcing new families from an existing port type). */
export function familyLabel(portTypes: PortType[], family: string): string {
return portTypes.find((pt) => pt.family === family)?.name ?? family
} }
/** The set of connector families a port type will mate with: its own family /** The set of connector families a port type will mate with: its own family
@@ -82,9 +94,9 @@ function cableFits(cableType: CableType, sourceFamilies: Set<string>, targetFami
/** Cable types usable with the given port type at (at least) one end — for /** Cable types usable with the given port type at (at least) one end — for
* a plain cable that means its family matches; for an adapter cable it's * a plain cable that means its family matches; for an adapter cable it's
* enough that either end matches. */ * enough that either end matches. */
export function cableTypesForPortType(project: Project, portType: PortType): CableType[] { export function cableTypesForPortType(catalog: Catalog, portType: PortType): CableType[] {
const families = acceptedFamilies(portType) const families = acceptedFamilies(portType)
return allCableTypes(project).filter((ct) => { return allCableTypes(catalog).filter((ct) => {
const [endA, endB] = cableEnds(ct) const [endA, endB] = cableEnds(ct)
return families.has(endA) || families.has(endB) return families.has(endA) || families.has(endB)
}) })
@@ -102,21 +114,17 @@ export function cableTypesForPortType(project: Project, portType: PortType): Cab
* aren't declared generally compatible still be wired with the right cable, * aren't declared generally compatible still be wired with the right cable,
* without changing either port type's own compatibility rules. * without changing either port type's own compatibility rules.
*/ */
export function cableTypesForConnection( export function cableTypesForConnection(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): CableType[] {
project: Project,
sourcePortType: PortType,
targetPortType: PortType,
): CableType[] {
const sourceFamilies = acceptedFamilies(sourcePortType) const sourceFamilies = acceptedFamilies(sourcePortType)
const targetFamilies = acceptedFamilies(targetPortType) const targetFamilies = acceptedFamilies(targetPortType)
return allCableTypes(project).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies)) return allCableTypes(catalog).filter((ct) => cableFits(ct, sourceFamilies, targetFamilies))
} }
/** Whether any cable type (built-in or custom) could bridge these two port /** Whether any cable type (built-in or custom) could bridge these two port
* types — used to allow a connection even when the port types aren't * types — used to allow a connection even when the port types aren't
* declared directly compatible, as long as a specific adapter cable exists. */ * declared directly compatible, as long as a specific adapter cable exists. */
export function hasBridgingCableType(project: Project, sourcePortType: PortType, targetPortType: PortType): boolean { export function hasBridgingCableType(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): boolean {
return cableTypesForConnection(project, sourcePortType, targetPortType).length > 0 return cableTypesForConnection(catalog, sourcePortType, targetPortType).length > 0
} }
/** /**
@@ -141,7 +149,7 @@ export function expandCompatibleFamilyIds(selectedPortTypeIds: string[], allType
return [...families] return [...families]
} }
export function createEmptyProject(name = 'Untitled Project'): Project { export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
const now = new Date().toISOString() const now = new Date().toISOString()
return { return {
id: uuid(), id: uuid(),
@@ -150,41 +158,26 @@ export function createEmptyProject(name = 'Untitled Project'): Project {
updatedAt: now, updatedAt: now,
devices: [], devices: [],
connections: [], connections: [],
customDeviceTemplates: [], bundles: [],
customPortTypes: [],
customCableTypes: [],
customDeviceCategories: [],
hiddenBuiltInDeviceTemplateIds: [],
} }
} }
/** Instantiates a device on the canvas from a template, deep-copying ports /** Instantiates a device on the canvas from a template, deep-copying ports
* (with fresh ids) so editing the instance never mutates the template. */ * (with fresh ids) so editing the instance never mutates the template. */
export function createDeviceFromTemplate( export function createDeviceFromTemplate(template: DeviceTemplate, position: { x: number; y: number }): Device {
template: DeviceTemplate,
position: { x: number; y: number },
): Device {
return { return {
id: uuid(), id: uuid(),
name: template.name, name: template.name,
templateId: template.id, templateId: template.id,
category: template.category, category: template.category,
manufacturer: template.manufacturer, manufacturerId: template.manufacturerId,
model: template.model, model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })), ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost,
position, position,
} }
} }
export function createBlankPort(overrides: Partial<Port> = {}): Port {
return {
id: uuid(),
name: overrides.name ?? 'New Port',
direction: overrides.direction ?? 'output',
portTypeId: overrides.portTypeId ?? BUILT_IN_PORT_TYPES[0].id,
}
}
/** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2". /** "CV Out 1" -> "CV Out 2", "Port 3" -> "Port 4", "Gate In" -> "Gate In 2".
* Only increments a number that's the very last thing in the name (trailing * Only increments a number that's the very last thing in the name (trailing
* whitespace aside), so labels like `1/4" TS In` aren't misread as ending * whitespace aside), so labels like `1/4" TS In` aren't misread as ending
-264
View File
@@ -1,264 +0,0 @@
import type { CableType, DeviceCategoryDef, DeviceTemplate, PortType } from './types'
/**
* Built-in connector/cable/device libraries. These ship read-only
* (`builtIn`/`custom: false`-ish via omission) and are merged with each
* project's custom entries at lookup time — see `domain/project.ts`.
*/
export const BUILT_IN_DEVICE_CATEGORIES: DeviceCategoryDef[] = [
{ id: 'display', name: 'Display' },
{ id: 'video-source', name: 'Video Source' },
{ id: 'audio-source', name: 'Audio Source' },
{ id: 'synth', name: 'Synthesizer / Sound Module' },
{ id: 'mixer', name: 'Mixer' },
{ id: 'amplifier', name: 'Amplifier / Receiver' },
{ id: 'speaker', name: 'Speaker' },
{ id: 'network', name: 'Network' },
{ id: 'computer', name: 'Computer' },
{ id: 'camera', name: 'Camera' },
{ id: 'control', name: 'Control' },
{ id: 'power', name: 'Power' },
{ id: 'adapter', name: 'Adapter / Converter' },
{ id: 'other', name: 'Other' },
]
export const BUILT_IN_PORT_TYPES: PortType[] = [
{ id: 'pt-hdmi', name: 'HDMI', category: 'video', family: 'hdmi' },
{ id: 'pt-displayport', name: 'DisplayPort', category: 'video', family: 'displayport' },
{ id: 'pt-sdi', name: 'SDI (BNC)', category: 'video', family: 'sdi' },
{ id: 'pt-vga', name: 'VGA', category: 'video', family: 'vga' },
{ id: 'pt-composite', name: 'Composite Video (RCA)', category: 'video', family: 'rca-composite' },
{ id: 'pt-xlr', name: 'XLR (Balanced Audio)', category: 'audio', family: 'xlr' },
{ id: 'pt-trs-quarter', name: '1/4" TRS (Balanced Audio)', category: 'audio', family: 'trs-quarter' },
{ id: 'pt-ts-quarter', name: '1/4" TS (Unbalanced Audio)', category: 'audio', family: 'ts-quarter' },
{ id: 'pt-trs-mini', name: '3.5mm TRS (Balanced Audio)', category: 'audio', family: 'trs-mini' },
{ id: 'pt-ts-mini', name: '3.5mm (Unbalanced Audio)', category: 'audio', family: 'ts-mini' },
{
id: 'pt-trs-quarter-combo',
name: '1/4" TRS/TS (Balanced or Unbalanced)',
category: 'audio',
family: 'trs-quarter-combo',
compatibleFamilyIds: ['trs-quarter', 'ts-quarter'],
},
{
id: 'pt-trs-mini-combo',
name: '3.5mm TRS/TS (Balanced or Unbalanced)',
category: 'audio',
family: 'trs-mini-combo',
compatibleFamilyIds: ['trs-mini', 'ts-mini'],
},
{ id: 'pt-rca-audio', name: 'RCA (Stereo Audio)', category: 'audio', family: 'rca-audio' },
{ id: 'pt-speakon', name: 'Speakon', category: 'audio', family: 'speakon' },
{ id: 'pt-speaker-wire', name: 'Speaker Wire (Bare/Terminal)', category: 'audio', family: 'speaker-wire', maxConnections: 1 },
{ id: 'pt-toslink', name: 'TOSLINK (Optical)', category: 'audio', family: 'toslink' },
{
id: 'pt-cat-rj45',
name: 'Cat5e/6 (RJ45)',
category: 'network',
family: 'cat-rj45',
compatibleFamilyIds: ['cat-rj45'],
},
{ id: 'pt-fiber-lc', name: 'Fiber (LC)', category: 'network', family: 'fiber-lc' },
{ id: 'pt-usb-a', name: 'USB-A (Unspecified)', category: 'usb', family: 'usb-a' },
{ id: 'pt-usb-c', name: 'USB-C (Unspecified)', category: 'usb', family: 'usb-c' },
{
id: 'pt-usb-a-host',
name: 'USB-A (Host)',
category: 'usb',
family: 'usb-a-host',
// 'usb-a-to-b' lets this pair with a USB-B device port via an A-to-B
// cable, distinct from the plain A-to-A cable used with usb-a-device.
compatibleFamilyIds: ['usb-a-device', 'usb-a-to-b'],
},
{
id: 'pt-usb-a-device',
name: 'USB-A (Device)',
category: 'usb',
family: 'usb-a-device',
compatibleFamilyIds: ['usb-a-host'],
},
{
id: 'pt-usb-c-host',
name: 'USB-C (Host)',
category: 'usb',
family: 'usb-c-host',
compatibleFamilyIds: ['usb-c-device', 'usb-c-to-b'],
},
{
id: 'pt-usb-c-device',
name: 'USB-C (Device)',
category: 'usb',
family: 'usb-c-device',
compatibleFamilyIds: ['usb-c-host'],
},
{
id: 'pt-usb-b-device',
name: 'USB-B (Device)',
category: 'usb',
family: 'usb-b-device',
// The classic "printer" connector — pairs with a host's USB-A or USB-C
// port via the corresponding shaped cable, not with another USB-B port.
compatibleFamilyIds: ['usb-a-to-b', 'usb-c-to-b'],
},
{ id: 'pt-iec-power', name: 'IEC C13/C14 Power', category: 'power', family: 'iec-power' },
{ id: 'pt-nema-power', name: 'NEMA 5-15 Power', category: 'power', family: 'nema-power' },
{ id: 'pt-rs232', name: 'RS-232 (Serial)', category: 'control', family: 'rs232' },
{ id: 'pt-ir', name: 'IR (3.5mm Emitter/Receiver)', category: 'control', family: 'ir' },
{ id: 'pt-midi-din', name: 'MIDI (5-Pin DIN)', category: 'control', family: 'midi-din' },
{
id: 'pt-midi-trs-a',
name: '3.5mm MIDI (Type A)',
category: 'control',
family: 'midi-trs-a',
// Deliberately NOT cross-compatible with Type B — the two standards
// swap tip/ring polarity, so a straight cable between them doesn't work.
},
{ id: 'pt-midi-trs-b', name: '3.5mm MIDI (Type B)', category: 'control', family: 'midi-trs-b' },
]
export const BUILT_IN_CABLE_TYPES: CableType[] = [
{ id: 'ct-hdmi', name: 'HDMI Cable', family: 'hdmi', unit: 'ft' },
{ id: 'ct-displayport', name: 'DisplayPort Cable', family: 'displayport', unit: 'ft' },
{ id: 'ct-sdi', name: 'SDI Coax (BNC)', family: 'sdi', unit: 'ft' },
{ id: 'ct-vga', name: 'VGA Cable', family: 'vga', unit: 'ft' },
{ id: 'ct-composite', name: 'Composite Video Cable (RCA)', family: 'rca-composite', unit: 'ft' },
{ id: 'ct-xlr', name: 'XLR Cable', family: 'xlr', unit: 'ft' },
{
id: 'ct-xlr-to-trs-quarter',
name: 'XLR to 1/4" TRS Adapter Cable',
family: 'xlr',
family2: 'trs-quarter',
unit: 'ft',
},
{ id: 'ct-trs-quarter', name: '1/4" TRS Cable (Balanced)', family: 'trs-quarter', unit: 'ft' },
{ id: 'ct-ts-quarter', name: '1/4" TS Cable (Instrument/Unbalanced)', family: 'ts-quarter', unit: 'ft' },
{ id: 'ct-trs-mini', name: '3.5mm TRS Cable (Balanced)', family: 'trs-mini', unit: 'ft' },
{ id: 'ct-ts-mini', name: '3.5mm Cable (Unbalanced)', family: 'ts-mini', unit: 'ft' },
{ id: 'ct-rca-audio', name: 'RCA Stereo Audio Cable', family: 'rca-audio', unit: 'ft' },
{ id: 'ct-speakon', name: 'Speakon Cable', family: 'speakon', unit: 'ft' },
{ id: 'ct-speaker-wire', name: 'Speaker Wire (14/2)', family: 'speaker-wire', unit: 'ft' },
{ id: 'ct-toslink', name: 'TOSLINK Optical Cable', family: 'toslink', unit: 'ft' },
{ id: 'ct-cat6', name: 'Cat6 Cable', family: 'cat-rj45', unit: 'ft' },
{ id: 'ct-fiber-lc', name: 'Fiber LC-LC Cable', family: 'fiber-lc', unit: 'ft' },
{ id: 'ct-usb-a', name: 'USB-A to USB-A Cable', family: 'usb-a', unit: 'ft' },
{ id: 'ct-usb-c', name: 'USB-C Cable', family: 'usb-c', unit: 'ft' },
{ id: 'ct-usb-a-host-device', name: 'USB-A Cable (Host ↔ Device)', family: 'usb-a-host', unit: 'ft' },
{ id: 'ct-usb-c-host-device', name: 'USB-C Cable (Host ↔ Device)', family: 'usb-c-host', unit: 'ft' },
{ id: 'ct-usb-a-to-b', name: 'USB-A to USB-B Cable', family: 'usb-a-to-b', unit: 'ft' },
{ id: 'ct-usb-c-to-b', name: 'USB-C to USB-B Cable', family: 'usb-c-to-b', unit: 'ft' },
{ id: 'ct-iec-power', name: 'IEC C13 Power Cable', family: 'iec-power', unit: 'ft' },
{ id: 'ct-nema-power', name: 'NEMA 5-15 Extension Cord', family: 'nema-power', unit: 'ft' },
{ id: 'ct-rs232', name: 'RS-232 Serial Cable', family: 'rs232', unit: 'ft' },
{ id: 'ct-ir', name: 'IR Emitter Cable', family: 'ir', unit: 'ft' },
{ id: 'ct-midi-din', name: 'MIDI Cable (5-Pin DIN)', family: 'midi-din', unit: 'ft' },
{ id: 'ct-midi-trs-a', name: '3.5mm MIDI Cable (Type A)', family: 'midi-trs-a', unit: 'ft' },
{ id: 'ct-midi-trs-b', name: '3.5mm MIDI Cable (Type B)', family: 'midi-trs-b', unit: 'ft' },
]
/** Small starter set of common AV device shapes; users add their own via the editor. */
export const BUILT_IN_DEVICE_TEMPLATES: DeviceTemplate[] = [
{
id: 'dt-display',
name: 'Display (TV / Projector)',
category: 'display',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-media-player',
name: 'Media Player / Streamer',
category: 'video-source',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
],
},
{
id: 'dt-avr',
name: 'AV Receiver',
category: 'amplifier',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI In 1', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'HDMI In 2', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p3', name: 'HDMI In 3', direction: 'input', portTypeId: 'pt-hdmi' },
{ id: 'p4', name: 'HDMI Out (Display)', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p5', name: 'Speaker Out L', direction: 'output', portTypeId: 'pt-speaker-wire' },
{ id: 'p6', name: 'Speaker Out R', direction: 'output', portTypeId: 'pt-speaker-wire' },
{ id: 'p7', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-speaker',
name: 'Speaker (Passive)',
category: 'speaker',
builtIn: true,
ports: [{ id: 'p1', name: 'Speaker In', direction: 'input', portTypeId: 'pt-speaker-wire' }],
},
{
id: 'dt-switch',
name: 'Network Switch',
category: 'network',
builtIn: true,
ports: [
{ id: 'p1', name: 'Port 1', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p2', name: 'Port 2', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p3', name: 'Port 3', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p4', name: 'Port 4', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p5', name: 'Power In', direction: 'input', portTypeId: 'pt-iec-power' },
],
},
{
id: 'dt-camera',
name: 'PTZ Camera',
category: 'camera',
builtIn: true,
ports: [
{ id: 'p1', name: 'SDI Out', direction: 'output', portTypeId: 'pt-sdi' },
{ id: 'p2', name: 'Control (RJ45)', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
{ id: 'p3', name: 'Power In', direction: 'input', portTypeId: 'pt-nema-power' },
],
},
{
id: 'dt-computer',
name: 'Computer / Laptop',
category: 'computer',
builtIn: true,
ports: [
{ id: 'p1', name: 'HDMI Out', direction: 'output', portTypeId: 'pt-hdmi' },
{ id: 'p2', name: 'USB-C', direction: 'bidirectional', portTypeId: 'pt-usb-c' },
{ id: 'p3', name: 'Ethernet', direction: 'bidirectional', portTypeId: 'pt-cat-rj45' },
],
},
// Adapters model a real standalone part (a barrel/dongle) as a tiny
// 2-port device, so a connection that genuinely needs two physical
// cables — e.g. 3.5mm MIDI into a DIN adapter, then a DIN cable onward —
// shows up in the BOM as what it actually is: one adapter + two cables,
// each with its own length, instead of being squeezed into one cable type.
{
id: 'dt-adapter-midi-trs-a-to-din',
name: '3.5mm-to-DIN MIDI Adapter (Type A)',
category: 'adapter',
builtIn: true,
ports: [
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-a' },
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
],
},
{
id: 'dt-adapter-midi-trs-b-to-din',
name: '3.5mm-to-DIN MIDI Adapter (Type B)',
category: 'adapter',
builtIn: true,
ports: [
{ id: 'p1', name: '3.5mm In (from MIDI Out)', direction: 'input', portTypeId: 'pt-midi-trs-b' },
{ id: 'p2', name: 'DIN Out (to MIDI In)', direction: 'output', portTypeId: 'pt-midi-din' },
],
},
]
+94 -15
View File
@@ -16,17 +16,28 @@ export type PortCategory =
| 'other' | 'other'
/** A device category id (references a DeviceCategoryDef). Kept as a plain /** A device category id (references a DeviceCategoryDef). Kept as a plain
* string rather than a fixed union so projects can define their own. */ * string rather than a fixed union so users can define their own. */
export type DeviceCategory = string export type DeviceCategory = string
/** A device grouping shown in the palette and category pickers. Built-ins /** A device grouping shown in the palette and category pickers. Built-ins
* ship in `library.ts`; users can add their own, scoped to the project. */ * ship in `library.ts`; users can add their own private ones via the
* shared catalog (see state/catalogStore.ts), not scoped to any one diagram. */
export interface DeviceCategoryDef { export interface DeviceCategoryDef {
id: string id: string
name: string name: string
custom?: boolean custom?: boolean
} }
/** A device manufacturer/brand, normalized the same way as DeviceCategoryDef
* (organized-ideas.md §3) rather than free text on each device — gives the
* Admin review queue's duplicate-detection nudge a reliable signal
* (matching manufacturer + similar model) that name-matching alone can't. */
export interface Manufacturer {
id: string
name: string
custom?: boolean
}
/** /**
* A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility * A kind of physical connector (HDMI, XLR, Cat6/RJ45, ...). Compatibility
* between two ports is decided by comparing `family` (plus optional * between two ports is decided by comparing `family` (plus optional
@@ -53,6 +64,14 @@ export interface Port {
name: string name: string
direction: PortDirection direction: PortDirection
portTypeId: string portTypeId: string
/** This port already has a captive/permanently-attached cable (a
* keyboard's USB cable, a budget AV receiver's power cord, ...) — a
* property of the physical port, not of any one wiring choice, so it
* lives here rather than on Connection. Any connection touching a port
* with this set doesn't need a separate purchased cable: the BOM omits
* it entirely, though the canvas still draws the link (styled
* differently) since the wiring topology is still real. */
builtInCable?: boolean
} }
/** A reusable, non-instantiated device definition (shown in the palette). */ /** A reusable, non-instantiated device definition (shown in the palette). */
@@ -60,10 +79,20 @@ export interface DeviceTemplate {
id: string id: string
name: string name: string
category: DeviceCategory category: DeviceCategory
manufacturer?: string /** References a Manufacturer catalog entry — optional since not every
* device has a known/chosen manufacturer (e.g. DIY or generic gear). */
manufacturerId?: string
model?: string model?: string
ports: Port[] ports: Port[]
builtIn?: boolean /** Suggested default cost, copied onto each placed Device at creation time
* (same as ports) and independently editable per instance afterward. */
cost?: number
/** True for your own private template (is_public = false, owner = you).
* Absent/false for anything public — whether an original seeded default
* or a user submission that's since been approved — since both behave
* the same way for you: not directly editable, only hideable from your
* own palette (see catalogStore's hidePublicDeviceTemplate). */
custom?: boolean
} }
/** A device placed on the canvas. Ports are copied from the template at /** A device placed on the canvas. Ports are copied from the template at
@@ -73,10 +102,16 @@ export interface Device {
name: string name: string
templateId?: string templateId?: string
category: DeviceCategory category: DeviceCategory
manufacturer?: string /** References a Manufacturer catalog entry, same as the template's —
* independently editable per instance afterward, like category/model. */
manufacturerId?: string
model?: string model?: string
notes?: string notes?: string
ports: Port[] ports: Port[]
/** Copied from the template's suggested cost when placed (like ports),
* then independently editable — editing an instance never mutates the
* shared template. */
cost?: number
position: { x: number; y: number } position: { x: number; y: number }
} }
@@ -95,6 +130,9 @@ export interface CableType {
* not just where this particular cable is actually used). */ * not just where this particular cable is actually used). */
family2?: string family2?: string
unit: 'ft' | 'm' unit: 'ft' | 'm'
/** Suggested default cost per unit length (in `unit`). Each Connection can
* override the computed cost for its specific run (`Connection.cost`),
* which always wins — public catalog prices drift and vary by vendor. */
costPerUnit?: number costPerUnit?: number
custom?: boolean custom?: boolean
} }
@@ -110,24 +148,65 @@ export interface Connection {
/** User-entered run length, in the CableType's unit. Optional because /** User-entered run length, in the CableType's unit. Optional because
* real-world layout isn't modeled (no floor plan / scale). */ * real-world layout isn't modeled (no floor plan / scale). */
distance?: number distance?: number
/** Override for this specific run's total cost — takes precedence over
* computing from the CableType's `costPerUnit * distance`, since a
* diagram-level price should always win over the library's suggested
* default. */
cost?: number
label?: string label?: string
notes?: string notes?: string
/** Set when this run is one leg of a multi-conductor physical cable (a
* stereo RCA pair, an 8-channel snake) — see CableBundle's own comment.
* Each leg keeps its own real ports and its own family-checked
* `cableTypeId` (still a valid single-run cable for *this* port pair);
* the bundle is purely a BOM/visual grouping layered on top. */
bundleId?: string
}
/**
* A physical multi-conductor cable that's wired as several independent
* point-to-point Connections (e.g. a stereo RCA pair's L and R legs, or an
* 8-channel snake's individual channels) but is bought and counted as one
* item. Deliberately decoupled from its members' own `cableTypeId`s — a
* member's cable type has to satisfy that specific port pair's family
* compatibility for wiring purposes, while this `cableTypeId` is purely
* "what to put on the shopping list" (often a catalog entry, like "8-Channel
* Snake", that wouldn't make sense as any single leg's own type). See
* domain/bom.ts: a connection with `bundleId` set stops contributing its own
* unit to the BOM; each bundle with at least one member contributes exactly
* one unit instead, using its own cableTypeId/distance/cost.
*/
export interface CableBundle {
id: string
name: string
cableTypeId: string
distance?: number
cost?: number
notes?: string
} }
export interface Project { export interface Diagram {
id: string id: string
name: string name: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
devices: Device[] devices: Device[]
connections: Connection[] connections: Connection[]
/** User-defined additions to the built-in libraries, scoped to this project. */ bundles: CableBundle[]
customDeviceTemplates: DeviceTemplate[] }
customPortTypes: PortType[]
customCableTypes: CableType[] /**
customDeviceCategories: DeviceCategoryDef[] * The full set of port types / cable types / device categories / device
/** Built-in device template ids the user has removed from their palette. * templates visible to the current user — public entries plus their own
* Hidden rather than deleted since built-ins are shared code, not * private ones, already merged (by Postgres RLS, at fetch time — see
* per-project data — this lets them be restored later. */ * state/catalogStore.ts), not scoped to any one diagram. Devices/ports/
hiddenBuiltInDeviceTemplateIds: string[] * connections reference these by id (a live reference, not a copy — see
* organized-ideas.md §1/§3 for why that's the deliberate choice).
*/
export interface Catalog {
portTypes: PortType[]
cableTypes: CableType[]
deviceCategories: DeviceCategoryDef[]
manufacturers: Manufacturer[]
deviceTemplates: DeviceTemplate[]
} }
+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
}
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand'
import type { AdminSubmissionRepository, UsageImpact } from '../data/AdminSubmissionRepository'
import type { CatalogSubmission } from '../data/SubmissionRepository'
import { SupabaseAdminSubmissionRepository } from '../data/SupabaseAdminSubmissionRepository'
import { useCatalogStore } from './catalogStore'
const repository: AdminSubmissionRepository = new SupabaseAdminSubmissionRepository()
interface AdminReviewStoreState {
/** Every submission across all users — RLS only returns this in full to
* an Admin/Super-Admin; a regular user calling this store would just get
* their own submissions back (same as submissionStore). */
allSubmissions: CatalogSubmission[]
/** Submitter username per submission id — see AdminSubmissionRepository.getSubmitterUsernames. */
submitterUsernames: Record<string, string>
isLoaded: boolean
loadAll: () => Promise<void>
/** Approves in place (same id, flips private -> public) and refreshes
* the shared catalog so every open view picks up the change. */
approve: (submission: CatalogSubmission) => Promise<void>
reject: (id: string, reason: string) => Promise<void>
getUsageImpact: (entityType: CatalogSubmission['entityType'], entityId: string) => Promise<UsageImpact>
}
export const useAdminReviewStore = create<AdminReviewStoreState>((set) => ({
allSubmissions: [],
submitterUsernames: {},
isLoaded: false,
loadAll: async () => {
const allSubmissions = await repository.listAll()
const submitterUsernames = await repository.getSubmitterUsernames(allSubmissions.map((s) => s.id))
set({ allSubmissions, submitterUsernames, isLoaded: true })
},
approve: async (submission) => {
await repository.approve(submission)
set((state) => ({
allSubmissions: state.allSubmissions.map((s) =>
s.id === submission.id ? { ...s, status: 'approved' as const, reviewReason: undefined } : s,
),
}))
await useCatalogStore.getState().loadCatalog()
},
reject: async (id, reason) => {
await repository.reject(id, reason)
set((state) => ({
allSubmissions: state.allSubmissions.map((s) =>
s.id === id ? { ...s, status: 'rejected' as const, reviewReason: reason } : s,
),
}))
},
getUsageImpact: (entityType, entityId) => repository.getUsageImpact(entityType, entityId),
}))
+91
View File
@@ -0,0 +1,91 @@
import { create } from 'zustand'
import type {
AccountMigrationImpact,
AccountMigrationRecord,
AdminUserRepository,
AdminUserSummary,
UserRole,
} from '../data/AdminUserRepository'
import { SupabaseAdminUserRepository } from '../data/SupabaseAdminUserRepository'
const repository: AdminUserRepository = new SupabaseAdminUserRepository()
interface AdminUserStoreState {
users: AdminUserSummary[]
isLoaded: boolean
/** The account-migration audit log — loaded on demand (opening the
* migration UI), not alongside users on every app load. */
migrations: AccountMigrationRecord[]
loadUsers: () => Promise<void>
updateRole: (userId: string, role: UserRole) => Promise<void>
updateCanPostAnnouncements: (userId: string, canPostAnnouncements: boolean) => Promise<void>
banUser: (userId: string) => Promise<void>
unbanUser: (userId: string) => Promise<void>
deleteUser: (userId: string) => Promise<void>
previewMigration: (fromUserId: string, toUserId: string) => Promise<AccountMigrationImpact>
/** Throws on failure (see AdminUserRepository.migrateAccount) — the
* caller is expected to catch and surface the reason. */
migrateAccount: (fromUserId: string, toUserId: string, verificationNotes: string) => Promise<AccountMigrationImpact>
loadMigrations: () => Promise<void>
}
export const useAdminUserStore = create<AdminUserStoreState>((set) => ({
users: [],
isLoaded: false,
migrations: [],
loadUsers: async () => {
const users = await repository.listUsers()
set({ users, isLoaded: true })
},
updateRole: async (userId, role) => {
await repository.updateRole(userId, role)
set((state) => ({ users: state.users.map((u) => (u.id === userId ? { ...u, role } : u)) }))
},
updateCanPostAnnouncements: async (userId, canPostAnnouncements) => {
await repository.updateCanPostAnnouncements(userId, canPostAnnouncements)
set((state) => ({
users: state.users.map((u) => (u.id === userId ? { ...u, canPostAnnouncements } : u)),
}))
},
banUser: async (userId) => {
await repository.banUser(userId)
// Re-fetch rather than guessing bannedUntil client-side — the Edge
// Function is the one place that actually knows the real value.
const users = await repository.listUsers()
set({ users })
},
unbanUser: async (userId) => {
await repository.unbanUser(userId)
const users = await repository.listUsers()
set({ users })
},
deleteUser: async (userId) => {
await repository.deleteUser(userId)
set((state) => ({ users: state.users.filter((u) => u.id !== userId) }))
},
previewMigration: (fromUserId, toUserId) => repository.previewAccountMigration(fromUserId, toUserId),
migrateAccount: async (fromUserId, toUserId, verificationNotes) => {
const impact = await repository.migrateAccount(fromUserId, toUserId, verificationNotes)
// Re-fetch rather than patch client-side — migratedToUserId/Username
// both need the fresh server-computed join, and the migration log needs
// its new row.
const [users, migrations] = await Promise.all([repository.listUsers(), repository.listAccountMigrations()])
set({ users, migrations })
return impact
},
loadMigrations: async () => {
const migrations = await repository.listAccountMigrations()
set({ migrations })
},
}))
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand'
import type { Announcement, AnnouncementRepository } from '../data/AnnouncementRepository'
import { SupabaseAnnouncementRepository } from '../data/SupabaseAnnouncementRepository'
const repository: AnnouncementRepository = new SupabaseAnnouncementRepository()
interface AnnouncementStoreState {
current: Announcement | null
isLoaded: boolean
load: () => Promise<void>
/** Dismisses the current announcement for the signed-in user — updates
* local state immediately rather than re-fetching, since the only thing
* that changed is this user's own dismissed flag. */
dismiss: () => Promise<void>
/** Posts a new announcement (retiring whichever was current) and reloads,
* so the poster immediately sees their own banner like everyone else. */
post: (message: string) => Promise<void>
retireCurrent: () => Promise<void>
}
export const useAnnouncementStore = create<AnnouncementStoreState>((set, get) => ({
current: null,
isLoaded: false,
load: async () => {
const current = await repository.getCurrent()
set({ current, isLoaded: true })
},
dismiss: async () => {
const { current } = get()
if (!current || current.dismissed) return
set({ current: { ...current, dismissed: true } })
await repository.dismiss(current.id)
},
post: async (message) => {
await repository.post(message)
await get().load()
},
retireCurrent: async () => {
await repository.retireCurrent()
await get().load()
},
}))
+53
View File
@@ -0,0 +1,53 @@
import { create } from 'zustand'
import type { UserRole } from '../data/AdminUserRepository'
import { supabase } from '../data/supabaseClient'
export type { UserRole }
interface AuthStoreState {
userId: string | null
username: string | null
role: UserRole | null
/** Lets a specific Admin post/retire site-wide announcements without the
* whole Admin role gaining that ability — a Super Admin can always post
* regardless of this flag (see can_manage_announcements() in the DB). */
canPostAnnouncements: boolean
isLoaded: boolean
/** One-time fetch of your own profile — nothing else in the app changes
* your own username while you're signed in, and your own role changing
* (a Super-Admin-only action on someone else's account, see
* organized-ideas.md §6) isn't something you'd do to yourself, so
* there's no need to keep this live. */
load: () => Promise<void>
}
export const useAuthStore = create<AuthStoreState>((set) => ({
userId: null,
username: null,
role: null,
canPostAnnouncements: false,
isLoaded: false,
load: async () => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
set({ isLoaded: true })
return
}
const { data } = await supabase
.from('profiles')
.select('username, role, can_post_announcements')
.eq('id', user.id)
.single()
set({
userId: user.id,
username: data?.username ?? null,
role: (data?.role as UserRole | undefined) ?? null,
canPostAnnouncements: data?.can_post_announcements ?? false,
isLoaded: true,
})
},
}))
+332
View File
@@ -0,0 +1,332 @@
import { create } from 'zustand'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../domain/types'
import type { CatalogRepository } from '../data/CatalogRepository'
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, manufacturerToRow, portTypeToRow } from '../data/catalogRowMapping'
import { SupabaseCatalogRepository } from '../data/SupabaseCatalogRepository'
import type { CatalogEntityType } from '../data/SubmissionRepository'
import { useSubmissionStore } from './submissionStore'
const repository: CatalogRepository = new SupabaseCatalogRepository()
// Which public device templates you've hidden from your own palette — a
// per-user UI preference, not catalog data, so it's not a Supabase row: hiding
// a public entry only affects your view of it (see domain/diagram.ts's
// allDeviceTemplates/hiddenDeviceTemplates), never the shared entry itself.
const HIDDEN_PUBLIC_TEMPLATES_KEY = 'av-planner:hiddenPublicDeviceTemplateIds'
function loadHiddenPublicIds(): Set<string> {
try {
const raw = window.localStorage.getItem(HIDDEN_PUBLIC_TEMPLATES_KEY)
return raw ? new Set(JSON.parse(raw)) : new Set()
} catch {
return new Set()
}
}
function saveHiddenPublicIds(ids: Set<string>): void {
try {
window.localStorage.setItem(HIDDEN_PUBLIC_TEMPLATES_KEY, JSON.stringify([...ids]))
} catch {
// Not essential — worst case a hidden template reappears next launch.
}
}
const emptyCatalog: Catalog = { portTypes: [], cableTypes: [], deviceCategories: [], manufacturers: [], deviceTemplates: [] }
interface CatalogStoreState {
catalog: Catalog
isLoaded: boolean
hiddenPublicDeviceTemplateIds: Set<string>
loadCatalog: () => Promise<void>
addCustomDeviceCategory: (name: string) => Promise<string>
updateCustomDeviceCategory: (id: string, name: string) => Promise<void>
removeCustomDeviceCategory: (id: string) => Promise<void>
addCustomManufacturer: (name: string) => Promise<string>
updateCustomManufacturer: (id: string, name: string) => Promise<void>
removeCustomManufacturer: (id: string) => Promise<void>
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
/** Throws (like removeCustomDeviceCategory) if the delete is rejected —
* most commonly a foreign-key violation because a device template's port
* still references it. */
removeCustomPortType: (id: string) => Promise<void>
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
removeCustomCableType: (id: string) => Promise<void>
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
removeCustomDeviceTemplate: (id: string) => Promise<void>
hidePublicDeviceTemplate: (id: string) => void
restorePublicDeviceTemplate: (id: string) => void
/** Admin/Super-Admin direct-to-public creation — skips the submission/
* review workflow, for authoring the shared library yourself (see
* CatalogRepository.adminAddDeviceCategory's own comment). */
adminAddDeviceCategory: (name: string) => Promise<string>
adminAddManufacturer: (name: string) => Promise<string>
adminAddPortType: (portType: Omit<PortType, 'id' | 'custom'>) => Promise<void>
adminAddCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => Promise<void>
adminAddDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'custom'>) => Promise<void>
/** Admin/Super-Admin direct edit of an already-public entry, per
* organized-ideas.md §6 — bypasses the submission/review workflow
* entirely (RLS's is_admin() clause is what actually permits it). */
adminUpdatePortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => Promise<void>
adminUpdateCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>) => Promise<void>
adminUpdateDeviceCategory: (id: string, name: string) => Promise<void>
adminUpdateManufacturer: (id: string, name: string) => Promise<void>
/** Unpublishes (is_public -> false) rather than deletes — see
* CatalogRepository.adminUnpublish. */
adminUnpublish: (entityType: CatalogEntityType, id: string) => Promise<void>
/** Permanent delete of a built-in entry — see
* CatalogRepository.adminRemoveDeviceCategory's own comment for the
* zero-usage contract the caller is expected to have already checked. */
adminRemoveDeviceCategory: (id: string) => Promise<void>
adminRemoveManufacturer: (id: string) => Promise<void>
adminRemovePortType: (id: string) => Promise<void>
adminRemoveCableType: (id: string) => Promise<void>
adminRemoveDeviceTemplate: (id: string) => Promise<void>
}
export const useCatalogStore = create<CatalogStoreState>((set, get) => ({
catalog: emptyCatalog,
isLoaded: false,
hiddenPublicDeviceTemplateIds: loadHiddenPublicIds(),
loadCatalog: async () => {
const catalog = await repository.load()
set({ catalog, isLoaded: true })
},
addCustomDeviceCategory: async (name) => {
const category = await repository.addDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
return category.id
},
updateCustomDeviceCategory: async (id, name) => {
await repository.updateDeviceCategory(id, name)
set((state) => ({
catalog: {
...state.catalog,
deviceCategories: state.catalog.deviceCategories.map((c) => (c.id === id ? { ...c, name } : c)),
},
}))
useSubmissionStore.getState().syncProposedData('device_category', id, deviceCategoryToRow(name))
},
removeCustomDeviceCategory: async (id) => {
await repository.removeDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminAddDeviceCategory: async (name) => {
const category = await repository.adminAddDeviceCategory(name)
set((state) => ({ catalog: { ...state.catalog, deviceCategories: [...state.catalog.deviceCategories, category] } }))
return category.id
},
addCustomManufacturer: async (name) => {
const manufacturer = await repository.addManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
updateCustomManufacturer: async (id, name) => {
await repository.updateManufacturer(id, name)
set((state) => ({
catalog: {
...state.catalog,
manufacturers: state.catalog.manufacturers.map((m) => (m.id === id ? { ...m, name } : m)),
},
}))
useSubmissionStore.getState().syncProposedData('manufacturer', id, manufacturerToRow(name))
},
removeCustomManufacturer: async (id) => {
await repository.removeManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminAddManufacturer: async (name) => {
const manufacturer = await repository.adminAddManufacturer(name)
set((state) => ({ catalog: { ...state.catalog, manufacturers: [...state.catalog.manufacturers, manufacturer] } }))
return manufacturer.id
},
addCustomPortType: async (portType) => {
const created = await repository.addPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
adminAddPortType: async (portType) => {
const created = await repository.adminAddPortType(portType)
set((state) => ({ catalog: { ...state.catalog, portTypes: [...state.catalog.portTypes, created] } }))
},
updateCustomPortType: async (id, patch) => {
await repository.updatePortType(id, patch)
set((state) => ({
catalog: {
...state.catalog,
portTypes: state.catalog.portTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
},
}))
const updated = get().catalog.portTypes.find((pt) => pt.id === id)
if (updated) useSubmissionStore.getState().syncProposedData('port_type', id, portTypeToRow(updated))
},
removeCustomPortType: async (id) => {
await repository.removePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
addCustomCableType: async (cableType) => {
const created = await repository.addCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
adminAddCableType: async (cableType) => {
const created = await repository.adminAddCableType(cableType)
set((state) => ({ catalog: { ...state.catalog, cableTypes: [...state.catalog.cableTypes, created] } }))
},
updateCustomCableType: async (id, patch) => {
await repository.updateCableType(id, patch)
set((state) => ({
catalog: {
...state.catalog,
cableTypes: state.catalog.cableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
},
}))
const updated = get().catalog.cableTypes.find((ct) => ct.id === id)
if (updated) useSubmissionStore.getState().syncProposedData('cable_type', id, cableTypeToRow(updated))
},
removeCustomCableType: async (id) => {
await repository.removeCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
addCustomDeviceTemplate: async (template) => {
const created = await repository.addDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
adminAddDeviceTemplate: async (template) => {
const created = await repository.adminAddDeviceTemplate(template)
set((state) => ({ catalog: { ...state.catalog, deviceTemplates: [...state.catalog.deviceTemplates, created] } }))
},
updateCustomDeviceTemplate: async (id, patch) => {
await repository.updateDeviceTemplate(id, patch)
set((state) => ({
catalog: {
...state.catalog,
deviceTemplates: state.catalog.deviceTemplates.map((t) => (t.id === id ? { ...t, ...patch } : t)),
},
}))
const updated = get().catalog.deviceTemplates.find((t) => t.id === id)
if (updated) useSubmissionStore.getState().syncProposedData('device_template', id, deviceTemplateToRow(updated))
},
removeCustomDeviceTemplate: async (id) => {
await repository.removeDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
hidePublicDeviceTemplate: (id) => {
const next = new Set(get().hiddenPublicDeviceTemplateIds)
next.add(id)
saveHiddenPublicIds(next)
set({ hiddenPublicDeviceTemplateIds: next })
},
restorePublicDeviceTemplate: (id) => {
const next = new Set(get().hiddenPublicDeviceTemplateIds)
next.delete(id)
saveHiddenPublicIds(next)
set({ hiddenPublicDeviceTemplateIds: next })
},
// Re-fetch the whole catalog after each of these rather than patching
// locally — device_template edits also touch device_template_ports, and
// unpublish changes which rows even show up (custom flips true for the
// Admin who did it, since is_admin() is the only thing still granting
// them visibility — see adminUnpublish's own comment). Simplest correct
// thing, and Admin actions here are infrequent enough that the extra
// round trip doesn't matter.
adminUpdatePortType: async (id, patch) => {
await repository.adminUpdatePortType(id, patch)
await get().loadCatalog()
},
adminUpdateCableType: async (id, patch) => {
await repository.adminUpdateCableType(id, patch)
await get().loadCatalog()
},
adminUpdateDeviceTemplate: async (id, patch) => {
await repository.adminUpdateDeviceTemplate(id, patch)
await get().loadCatalog()
},
adminUpdateDeviceCategory: async (id, name) => {
await repository.adminUpdateDeviceCategory(id, name)
await get().loadCatalog()
},
adminUpdateManufacturer: async (id, name) => {
await repository.adminUpdateManufacturer(id, name)
await get().loadCatalog()
},
adminUnpublish: async (entityType, id) => {
await repository.adminUnpublish(entityType, id)
await get().loadCatalog()
},
adminRemoveDeviceCategory: async (id) => {
await repository.adminRemoveDeviceCategory(id)
set((state) => ({
catalog: { ...state.catalog, deviceCategories: state.catalog.deviceCategories.filter((c) => c.id !== id) },
}))
},
adminRemoveManufacturer: async (id) => {
await repository.adminRemoveManufacturer(id)
set((state) => ({
catalog: { ...state.catalog, manufacturers: state.catalog.manufacturers.filter((m) => m.id !== id) },
}))
},
adminRemovePortType: async (id) => {
await repository.adminRemovePortType(id)
set((state) => ({ catalog: { ...state.catalog, portTypes: state.catalog.portTypes.filter((pt) => pt.id !== id) } }))
},
adminRemoveCableType: async (id) => {
await repository.adminRemoveCableType(id)
set((state) => ({ catalog: { ...state.catalog, cableTypes: state.catalog.cableTypes.filter((ct) => ct.id !== id) } }))
},
adminRemoveDeviceTemplate: async (id) => {
await repository.adminRemoveDeviceTemplate(id)
set((state) => ({
catalog: { ...state.catalog, deviceTemplates: state.catalog.deviceTemplates.filter((t) => t.id !== id) },
}))
},
}))
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand'
import type {
CollaboratorPermission,
DiagramCollaborator,
DiagramCollaboratorRepository,
} from '../data/DiagramCollaboratorRepository'
import { SupabaseDiagramCollaboratorRepository } from '../data/SupabaseDiagramCollaboratorRepository'
const repository: DiagramCollaboratorRepository = new SupabaseDiagramCollaboratorRepository()
interface DiagramCollaboratorStoreState {
collaborators: DiagramCollaborator[]
isLoaded: boolean
load: (diagramId: string) => Promise<void>
add: (diagramId: string, username: string, permission: CollaboratorPermission) => Promise<void>
updatePermission: (diagramId: string, userId: string, permission: CollaboratorPermission) => Promise<void>
remove: (diagramId: string, userId: string) => Promise<void>
}
export const useDiagramCollaboratorStore = create<DiagramCollaboratorStoreState>((set) => ({
collaborators: [],
isLoaded: false,
load: async (diagramId) => {
const collaborators = await repository.list(diagramId)
set({ collaborators, isLoaded: true })
},
add: async (diagramId, username, permission) => {
const created = await repository.add(diagramId, username, permission)
set((state) => ({ collaborators: [...state.collaborators, created] }))
},
updatePermission: async (diagramId, userId, permission) => {
await repository.updatePermission(diagramId, userId, permission)
set((state) => ({
collaborators: state.collaborators.map((c) => (c.userId === userId ? { ...c, permission } : c)),
}))
},
remove: async (diagramId, userId) => {
await repository.remove(diagramId, userId)
set((state) => ({ collaborators: state.collaborators.filter((c) => c.userId !== userId) }))
},
}))
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from '../data/DiagramSnapshotRepository'
import { SupabaseDiagramSnapshotRepository } from '../data/SupabaseDiagramSnapshotRepository'
import type { Diagram } from '../domain/types'
import { useDiagramStore } from './diagramStore'
const repository: DiagramSnapshotRepository = new SupabaseDiagramSnapshotRepository()
interface DiagramSnapshotStoreState {
snapshots: DiagramSnapshotSummary[]
isLoaded: boolean
load: (diagramId: string) => Promise<void>
/** Restores a snapshot and applies it to the open diagram (via
* diagramStore.applyRestoredDiagram) in one step. Skips writing a
* pointless duplicate safety snapshot when the live diagram is already
* an unmodified copy of a snapshot still sitting in the list — see
* diagramStore's restoredFromSnapshotId/restoredDiagramUpdatedAt.
* Returns the restored Diagram, or null if the restore failed. */
restore: (diagramId: string, snapshotId: string) => Promise<Diagram | null>
}
export const useDiagramSnapshotStore = create<DiagramSnapshotStoreState>((set) => ({
snapshots: [],
isLoaded: false,
load: async (diagramId) => {
const snapshots = await repository.list(diagramId)
set({ snapshots, isLoaded: true })
},
restore: async (diagramId, snapshotId) => {
const { diagram, restoredFromSnapshotId, restoredDiagramUpdatedAt } = useDiagramStore.getState()
const knownUnmodifiedSinceLastRestore =
restoredFromSnapshotId !== null && diagram.updatedAt === restoredDiagramUpdatedAt
const restored = await repository.restore(diagramId, snapshotId, !knownUnmodifiedSinceLastRestore)
if (restored) {
useDiagramStore.getState().applyRestoredDiagram(restored, snapshotId)
const snapshots = await repository.list(diagramId)
set({ snapshots })
}
return restored
},
}))
+480
View File
@@ -0,0 +1,480 @@
import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram'
import type { CableBundle, Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
// This is exactly the swap the repository pattern was built for: only this
// one line changed to move off localStorage-only and onto real (locally
// self-hosted, for now) Supabase persistence — nothing else in the store or
// UI layers needed to change. LocalStorageDiagramRepository is still in the
// codebase as a reference implementation / fallback, just no longer active.
const repository: DiagramRepository = new SupabaseDiagramRepository()
// Which diagram to reopen on next launch — a session/UI preference, not app
// data, so it lives in localStorage even though the diagrams themselves are
// in Supabase now. Falls back gracefully (see loadInitialDiagram) if the
// referenced diagram no longer exists.
const LAST_DIAGRAM_ID_KEY = 'av-planner:lastDiagramId'
function getLastDiagramId(): string | null {
try {
return window.localStorage.getItem(LAST_DIAGRAM_ID_KEY)
} catch {
return null
}
}
function setLastDiagramId(id: string): void {
try {
window.localStorage.setItem(LAST_DIAGRAM_ID_KEY, id)
} catch {
// Not essential — worst case, next launch falls back to the most
// recently updated diagram instead of this exact one.
}
}
async function loadMostRecent(): Promise<Diagram | null> {
const list = await repository.list()
if (list.length === 0) return null
return repository.loadById(list[0].id)
}
interface DiagramStoreState {
diagram: Diagram
isLoaded: boolean
/** Every diagram the current user can see, most recently updated first.
* Populated by refreshDiagramList — not kept live-synced on every store
* change, just refreshed at the moments that matter (initial load, and
* whenever the diagram manager UI opens or changes something). */
diagramList: DiagramSummary[]
/** Who owns the *currently open* diagram and what the current user can do
* with it — refreshed whenever the open diagram changes, not kept live
* (matches §8's turn-based, not realtime, collaboration model: a
* permission change made by the owner while you're viewing takes effect
* next time you load the diagram, not mid-session). Null only during the
* brief window before the very first diagram finishes loading. */
access: DiagramAccess | null
/** Set to a snapshot's id right after restoring it, meaning the live
* diagram data is verbatim identical to that already-existing snapshot
* row — see applyRestoredDiagram. `restoredDiagramUpdatedAt` is the
* diagram's own `updatedAt` at that moment; touch() always advances
* `updatedAt` on a genuine edit, so comparing the two later is a free
* way to tell "has anything changed since this restore" without
* hooking every mutating action. Both null once anything invalidates
* the match (an edit, or loading/switching to a diagram fresh). Used by
* DiagramSnapshotStore to skip writing a pointless duplicate safety
* snapshot when the user restores several old versions in a row without
* editing in between. */
restoredFromSnapshotId: string | null
restoredDiagramUpdatedAt: string | null
selectedDeviceId: string | null
selectedConnectionId: string | null
lastConnectionError: string | null
clearConnectionError: () => void
/** Figures out which diagram to open on launch (last one you had open, else
* most recently updated, else a fresh empty one) and loads it. */
loadInitialDiagram: () => Promise<void>
refreshDiagramList: () => Promise<void>
/** Creates a new diagram, makes it current, and persists it immediately
* (not just via the debounced autosave) so it shows up in the list right away. */
newDiagram: (name?: string) => Promise<void>
renameDiagram: (name: string) => void
/** Imports diagram data under a fresh id, so it's added as a new diagram
* rather than risking a collision with (or silently overwriting) an
* existing one that happens to share the file's id. */
importDiagram: (diagram: Diagram) => Promise<void>
/** Applies a diagram that was already persisted by something else (the
* version-history "Restore" action already wrote the restored data to
* Supabase) — just reflects it in the currently open canvas, without
* re-triggering an import-style "treat this as a brand new diagram". */
applyRestoredDiagram: (diagram: Diagram, snapshotId: string) => void
/** Switches the active diagram to another one of the user's own. */
switchToDiagram: (id: string) => Promise<void>
/** Permanently deletes a diagram. If it's the currently open one, falls
* back to another existing diagram, or a fresh empty one if none remain. */
deleteDiagram: (id: string) => Promise<void>
selectDevice: (id: string | null) => void
selectConnection: (id: string | null) => void
addDeviceFromTemplate: (catalog: Catalog, templateId: string, position: { x: number; y: number }) => void
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (
id: string,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
) => void
removeDevice: (id: string) => void
/** Returns the new port's id, so the caller can move keyboard focus to it. */
addPort: (deviceId: string, port: Omit<Port, 'id'>) => string
/** Inserts a copy of the port right after the original (name auto-incremented) and
* returns the new port's id, so the caller can move keyboard focus to it. */
clonePort: (deviceId: string, portId: string) => string
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
removePort: (deviceId: string, portId: string) => void
addConnection: (
catalog: Catalog,
params: {
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
cableTypeId: string
},
) => boolean
updateConnection: (
id: string,
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
) => void
removeConnection: (id: string) => void
/** Creates a new bundle containing just this one connection — the first
* leg of a stereo pair, snake, etc. Further legs join via
* addConnectionToBundle. */
startBundle: (connectionId: string, fields: Omit<CableBundle, 'id'>) => void
addConnectionToBundle: (connectionId: string, bundleId: string) => void
/** Clears this connection's bundleId; deletes the bundle itself if that
* was its last remaining member. */
removeConnectionFromBundle: (connectionId: string) => void
updateBundle: (bundleId: string, patch: Partial<Omit<CableBundle, 'id'>>) => void
}
function touch(diagram: Diagram): Diagram {
return { ...diagram, updatedAt: new Date().toISOString() }
}
/** Drops any bundle no connection references anymore — called after
* anything that could zero out a bundle's membership (removing a
* connection, or un-bundling its last remaining leg), so an empty bundle
* never lingers in the diagram for a user to stumble on later. */
function pruneEmptyBundles(diagram: Diagram): Diagram {
const referencedIds = new Set(diagram.connections.map((c) => c.bundleId).filter((id): id is string => !!id))
const bundles = diagram.bundles.filter((b) => referencedIds.has(b.id))
return bundles.length === diagram.bundles.length ? diagram : { ...diagram, bundles }
}
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
diagram: createEmptyDiagram(),
isLoaded: false,
diagramList: [],
access: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
clearConnectionError: () => set({ lastConnectionError: null }),
loadInitialDiagram: async () => {
const lastId = getLastDiagramId()
const loaded = lastId ? await repository.loadById(lastId) : null
const diagram = loaded ?? (await loadMostRecent()) ?? createEmptyDiagram()
setLastDiagramId(diagram.id)
const access = await repository.getAccess(diagram.id)
set({ diagram, access, isLoaded: true, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null })
get().refreshDiagramList()
},
refreshDiagramList: async () => {
const diagramList = await repository.list()
set({ diagramList })
},
newDiagram: async (name = 'Untitled Diagram') => {
const diagram = createEmptyDiagram(name)
setLastDiagramId(diagram.id)
set({
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(diagram)
const access = await repository.getAccess(diagram.id)
set({ access })
get().refreshDiagramList()
},
renameDiagram: (name) => {
set((state) => ({ diagram: touch({ ...state.diagram, name }) }))
},
applyRestoredDiagram: (diagram, snapshotId) => {
set({
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: snapshotId,
restoredDiagramUpdatedAt: diagram.updatedAt,
})
},
importDiagram: async (diagram) => {
const fresh = touch({ ...diagram, id: uuid() })
setLastDiagramId(fresh.id)
set({
diagram: fresh,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(fresh)
const access = await repository.getAccess(fresh.id)
set({ access })
get().refreshDiagramList()
},
switchToDiagram: async (id) => {
if (id === get().diagram.id) return
const loaded = await repository.loadById(id)
if (!loaded) return
setLastDiagramId(loaded.id)
const access = await repository.getAccess(loaded.id)
set({
diagram: loaded,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
},
deleteDiagram: async (id) => {
await repository.deleteById(id)
if (get().diagram.id === id) {
const next = (await loadMostRecent()) ?? createEmptyDiagram()
setLastDiagramId(next.id)
const access = await repository.getAccess(next.id)
set({
diagram: next,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
}
get().refreshDiagramList()
},
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
addDeviceFromTemplate: (catalog, templateId, position) => {
const { diagram } = get()
const template = allDeviceTemplates(catalog).find((t) => t.id === templateId)
if (!template) return
const device = createDeviceFromTemplate(template, position)
set({ diagram: touch({ ...diagram, devices: [...diagram.devices, device] }), selectedDeviceId: device.id })
},
updateDevicePosition: (id, position) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, position } : d)),
}),
}))
},
updateDevice: (id, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
}),
}))
},
removeDevice: (id) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.filter((d) => d.id !== id),
connections: state.diagram.connections.filter(
(c) => c.sourceDeviceId !== id && c.targetDeviceId !== id,
),
}),
selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId,
}))
},
addPort: (deviceId, port) => {
const newId = uuid()
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
),
}),
}))
return newId
},
updatePort: (deviceId, portId, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId
? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) }
: d,
),
}),
}))
},
clonePort: (deviceId, portId) => {
const newId = uuid()
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => {
if (d.id !== deviceId) return d
const index = d.ports.findIndex((p) => p.id === portId)
if (index === -1) return d
const source = d.ports[index]
const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) }
return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] }
}),
}),
}))
return newId
},
removePort: (deviceId, portId) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d,
),
connections: state.diagram.connections.filter(
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
),
}),
}))
},
addConnection: (catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
const { diagram } = get()
const validation = validateConnection(diagram, catalog, {
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
})
if (!validation.valid) {
set({ lastConnectionError: validation.reason ?? 'Invalid connection.' })
return false
}
const connection: Connection = {
id: uuid(),
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
cableTypeId,
}
set({
diagram: touch({ ...diagram, connections: [...diagram.connections, connection] }),
lastConnectionError: null,
selectedConnectionId: connection.id,
selectedDeviceId: null,
})
return true
},
updateConnection: (id, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
connections: state.diagram.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
}),
}))
},
removeConnection: (id) => {
set((state) => ({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.filter((c) => c.id !== id),
}),
),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
},
startBundle: (connectionId, fields) => {
const bundle: CableBundle = { id: uuid(), ...fields }
set((state) => ({
diagram: touch({
...state.diagram,
bundles: [...state.diagram.bundles, bundle],
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: bundle.id } : c,
),
}),
}))
},
addConnectionToBundle: (connectionId, bundleId) => {
set((state) => ({
diagram: touch({
...state.diagram,
connections: state.diagram.connections.map((c) => (c.id === connectionId ? { ...c, bundleId } : c)),
}),
}))
},
removeConnectionFromBundle: (connectionId) => {
set((state) => ({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: undefined } : c,
),
}),
),
}))
},
updateBundle: (bundleId, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
bundles: state.diagram.bundles.map((b) => (b.id === bundleId ? { ...b, ...patch } : b)),
}),
}))
},
}))
// Debounced autosave: fires whenever `diagram` changes (skipped until the
// initial load completes, so we don't immediately overwrite storage with a
// blank diagram on first mount).
let saveTimeout: ReturnType<typeof setTimeout> | undefined
useDiagramStore.subscribe((state, prevState) => {
// Skip the isLoaded:false -> true transition itself (that's the initial
// load, not a user edit) and any tick where `diagram` didn't change.
if (!prevState.isLoaded) return
if (state.diagram === prevState.diagram) return
// Defense in depth: the UI shouldn't expose any mutating control to a
// view-only collaborator in the first place (see FlowCanvas/inspectors),
// but skip the write here too rather than relying on RLS's silent no-op.
if (state.access?.myPermission === 'view') return
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
repository.save(state.diagram).catch((err) => console.error('Autosave failed', err))
}, 400)
})
export type { DeviceCategory }
-361
View File
@@ -1,361 +0,0 @@
import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
import { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository'
import type { ProjectRepository } from '../data/ProjectRepository'
// Swapping to a backend later means constructing a different repository here
// (or injecting one) — nothing else in the store changes.
const repository: ProjectRepository = new LocalStorageProjectRepository()
interface ProjectStoreState {
project: Project
isLoaded: boolean
selectedDeviceId: string | null
selectedConnectionId: string | null
lastConnectionError: string | null
clearConnectionError: () => void
loadFromStorage: () => Promise<void>
newProject: (name?: string) => void
renameProject: (name: string) => void
importProject: (project: Project) => void
selectDevice: (id: string | null) => void
selectConnection: (id: string | null) => void
addDeviceFromTemplate: (templateId: string, position: { x: number; y: number }) => void
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (id: string, patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturer' | 'model' | 'notes'>>) => void
removeDevice: (id: string) => void
/** Returns the new port's id, so the caller can move keyboard focus to it. */
addPort: (deviceId: string, port: Omit<Port, 'id'>) => string
/** Inserts a copy of the port right after the original (name auto-incremented) and
* returns the new port's id, so the caller can move keyboard focus to it. */
clonePort: (deviceId: string, portId: string) => string
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
removePort: (deviceId: string, portId: string) => void
addConnection: (params: {
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
cableTypeId: string
}) => boolean
updateConnection: (id: string, patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'label' | 'notes'>>) => void
removeConnection: (id: string) => void
addCustomDeviceTemplate: (template: Omit<DeviceTemplate, 'id' | 'builtIn'>) => void
updateCustomDeviceTemplate: (id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'builtIn'>>) => void
removeCustomDeviceTemplate: (id: string) => void
hideBuiltInDeviceTemplate: (id: string) => void
restoreBuiltInDeviceTemplate: (id: string) => void
addCustomPortType: (portType: Omit<PortType, 'id' | 'custom'>) => void
updateCustomPortType: (id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>) => void
addCustomCableType: (cableType: Omit<CableType, 'id' | 'custom'>) => void
updateCustomCableType: (id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>) => void
/** Returns the new category's id, so the caller can select it immediately. */
addCustomDeviceCategory: (name: string) => string
}
function touch(project: Project): Project {
return { ...project, updatedAt: new Date().toISOString() }
}
export const useProjectStore = create<ProjectStoreState>((set, get) => ({
project: createEmptyProject(),
isLoaded: false,
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
clearConnectionError: () => set({ lastConnectionError: null }),
loadFromStorage: async () => {
const loaded = await repository.load()
set({ project: loaded ?? createEmptyProject(), isLoaded: true })
},
newProject: (name = 'Untitled Project') => {
set({ project: createEmptyProject(name), selectedDeviceId: null, selectedConnectionId: null })
},
renameProject: (name) => {
set((state) => ({ project: touch({ ...state.project, name }) }))
},
importProject: (project) => {
set({ project: touch(project), selectedDeviceId: null, selectedConnectionId: null })
},
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
addDeviceFromTemplate: (templateId, position) => {
const { project } = get()
const template = allDeviceTemplates(project).find((t) => t.id === templateId)
if (!template) return
const device = createDeviceFromTemplate(template, position)
set({ project: touch({ ...project, devices: [...project.devices, device] }), selectedDeviceId: device.id })
},
updateDevicePosition: (id, position) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => (d.id === id ? { ...d, position } : d)),
}),
}))
},
updateDevice: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
}),
}))
},
removeDevice: (id) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.filter((d) => d.id !== id),
connections: state.project.connections.filter(
(c) => c.sourceDeviceId !== id && c.targetDeviceId !== id,
),
}),
selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId,
}))
},
addPort: (deviceId, port) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
),
}),
}))
return newId
},
updatePort: (deviceId, portId, patch) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId
? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) }
: d,
),
}),
}))
},
clonePort: (deviceId, portId) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) => {
if (d.id !== deviceId) return d
const index = d.ports.findIndex((p) => p.id === portId)
if (index === -1) return d
const source = d.ports[index]
const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) }
return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] }
}),
}),
}))
return newId
},
removePort: (deviceId, portId) => {
set((state) => ({
project: touch({
...state.project,
devices: state.project.devices.map((d) =>
d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d,
),
connections: state.project.connections.filter(
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
),
}),
}))
},
addConnection: ({ sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
const { project } = get()
const validation = validateConnection(project, {
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
})
if (!validation.valid) {
set({ lastConnectionError: validation.reason ?? 'Invalid connection.' })
return false
}
const connection: Connection = {
id: uuid(),
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
cableTypeId,
}
set({
project: touch({ ...project, connections: [...project.connections, connection] }),
lastConnectionError: null,
selectedConnectionId: connection.id,
selectedDeviceId: null,
})
return true
},
updateConnection: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
connections: state.project.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
}),
}))
},
removeConnection: (id) => {
set((state) => ({
project: touch({
...state.project,
connections: state.project.connections.filter((c) => c.id !== id),
}),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
},
addCustomDeviceTemplate: (template) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: [
...state.project.customDeviceTemplates,
{ ...template, id: uuid() },
],
}),
}))
},
updateCustomDeviceTemplate: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: state.project.customDeviceTemplates.map((t) =>
t.id === id ? { ...t, ...patch } : t,
),
}),
}))
},
removeCustomDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
customDeviceTemplates: state.project.customDeviceTemplates.filter((t) => t.id !== id),
}),
}))
},
hideBuiltInDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.includes(id)
? state.project.hiddenBuiltInDeviceTemplateIds
: [...state.project.hiddenBuiltInDeviceTemplateIds, id],
}),
}))
},
restoreBuiltInDeviceTemplate: (id) => {
set((state) => ({
project: touch({
...state.project,
hiddenBuiltInDeviceTemplateIds: state.project.hiddenBuiltInDeviceTemplateIds.filter((h) => h !== id),
}),
}))
},
addCustomPortType: (portType) => {
set((state) => ({
project: touch({
...state.project,
customPortTypes: [...state.project.customPortTypes, { ...portType, id: uuid(), custom: true }],
}),
}))
},
updateCustomPortType: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customPortTypes: state.project.customPortTypes.map((pt) => (pt.id === id ? { ...pt, ...patch } : pt)),
}),
}))
},
addCustomCableType: (cableType) => {
set((state) => ({
project: touch({
...state.project,
customCableTypes: [...state.project.customCableTypes, { ...cableType, id: uuid(), custom: true }],
}),
}))
},
updateCustomCableType: (id, patch) => {
set((state) => ({
project: touch({
...state.project,
customCableTypes: state.project.customCableTypes.map((ct) => (ct.id === id ? { ...ct, ...patch } : ct)),
}),
}))
},
addCustomDeviceCategory: (name) => {
const newId = uuid()
set((state) => ({
project: touch({
...state.project,
customDeviceCategories: [
...state.project.customDeviceCategories,
{ id: newId, name, custom: true },
],
}),
}))
return newId
},
}))
// Debounced autosave: fires whenever `project` changes (skipped until the
// initial load completes, so we don't immediately overwrite storage with a
// blank project on first mount).
let saveTimeout: ReturnType<typeof setTimeout> | undefined
useProjectStore.subscribe((state, prevState) => {
// Skip the isLoaded:false -> true transition itself (that's the initial
// load, not a user edit) and any tick where `project` didn't change.
if (!prevState.isLoaded) return
if (state.project === prevState.project) return
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
repository.save(state.project).catch((err) => console.error('Autosave failed', err))
}, 400)
})
export type { DeviceCategory }
+64
View File
@@ -0,0 +1,64 @@
import { create } from 'zustand'
import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from '../data/SubmissionRepository'
import { SupabaseSubmissionRepository } from '../data/SupabaseSubmissionRepository'
const repository: SubmissionRepository = new SupabaseSubmissionRepository()
interface SubmissionStoreState {
mySubmissions: CatalogSubmission[]
isLoaded: boolean
loadMySubmissions: () => Promise<void>
/** Submits `entityId` (your own private entry, or a public entry you're
* proposing an edit to) for Admin review. */
submitForReview: (entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>) => Promise<void>
/** Revises a pending/rejected submission of your own and puts it back in the queue. */
resubmit: (id: string, proposedData: Record<string, unknown>) => Promise<void>
/** Keeps a pending/rejected submission's proposed_data matching the live
* entity it's about, without touching its status — see catalogStore,
* which calls this after every edit to one of your private catalog
* entries. A no-op if you have no pending/rejected submission for that
* entity (the common case — most edits were never submitted at all). */
syncProposedData: (entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>) => Promise<void>
withdraw: (id: string) => Promise<void>
}
export const useSubmissionStore = create<SubmissionStoreState>((set, get) => ({
mySubmissions: [],
isLoaded: false,
loadMySubmissions: async () => {
const mySubmissions = await repository.listMine()
set({ mySubmissions, isLoaded: true })
},
submitForReview: async (entityType, entityId, proposedData) => {
const created = await repository.submit(entityType, entityId, proposedData)
set((state) => ({ mySubmissions: [created, ...state.mySubmissions] }))
},
resubmit: async (id, proposedData) => {
await repository.resubmit(id, proposedData)
set((state) => ({
mySubmissions: state.mySubmissions.map((s) =>
s.id === id ? { ...s, proposedData, status: 'pending', reviewerId: undefined, reviewReason: undefined } : s,
),
}))
},
syncProposedData: async (entityType, entityId, proposedData) => {
const match = get().mySubmissions.find(
(s) => s.entityType === entityType && s.entityId === entityId && (s.status === 'pending' || s.status === 'rejected'),
)
if (!match) return
await repository.updateProposedData(match.id, proposedData)
set((state) => ({
mySubmissions: state.mySubmissions.map((s) => (s.id === match.id ? { ...s, proposedData } : s)),
}))
},
withdraw: async (id) => {
await repository.withdraw(id)
set((state) => ({ mySubmissions: state.mySubmissions.filter((s) => s.id !== id) }))
},
}))
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string
readonly VITE_SUPABASE_ANON_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

Some files were not shown because too many files have changed in this diff Show More