Compare commits

..
25 Commits
Author SHA1 Message Date
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
121 changed files with 13229 additions and 1315 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=
+16 -13
View File
@@ -1,4 +1,4 @@
# AV Planner
# Diagrav
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
@@ -18,13 +18,14 @@ src/
domain/ Framework-agnostic types + logic (compatibility rules, BOM math,
the built-in connector/cable/device library). No React, no
React Flow — safe to unit test or reuse from a future backend.
data/ Storage abstraction. `ProjectRepository` is the interface the
rest of the app codes against; `LocalStorageProjectRepository`
is the only implementation today. Swapping in a real backend
later (REST/GraphQL) means adding one new implementation of
that interface, not touching state/UI code.
state/ Zustand store (`projectStore`) — the single source of truth for
the current project. Auto-saves to the repository (debounced)
data/ Storage abstraction. `DiagramRepository` is the interface the
rest of the app codes against; `SupabaseDiagramRepository` is
the active implementation, with `LocalStorageDiagramRepository`
kept as a reference/fallback. Swapping storage means adding
one new implementation of that interface, not touching
state/UI code.
state/ Zustand store (`diagramStore`) — the single source of truth for
the current diagram. Auto-saves to the repository (debounced)
on every change.
components/
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
wired together only if their PortTypes share a compatibility `family`.
- **DeviceTemplate** — a reusable device shape (its ports), shown in the
palette. Built-ins live in `domain/library.ts`; custom ones are saved on the
project.
palette. Built-ins live in `domain/library.ts` and seed the public catalog;
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
(editing an instance never mutates its template).
- **CableType** — a physical cable spec, scoped to a PortType family.
- **Connection** — a wire between two ports, referencing a CableType and an
optional user-entered distance (there's no floor plan / scale model, so
length is a manual estimate per connection).
- **Project** — devices + connections + any custom library entries. This is
the one object persisted (auto-saved to `localStorage`, or explicitly
exported/imported as JSON).
- **Diagram** — devices + connections. This is the one object persisted
(auto-saved to Supabase, or explicitly exported/imported as JSON); the
catalog (PortType/CableType/DeviceTemplate/category) is shared and global,
not part of any one diagram.
## Development
+188
View File
@@ -0,0 +1,188 @@
# Deployment & CI/CD planning notes
Discussion notes only — nothing here has been implemented yet. Written so a
different Claude Code conversation on this repo can pick up where this one
left off without re-deriving it. Nothing below is committed to as final; it's
a record of what was discussed and concluded, plus open decisions.
## Hosting shape (discussed, not yet 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 plan: Gitea + Woodpecker, pushbutton staging/prod
Repo is self-hosted on Gitea (`git.halfbinary.net/aarbit/av-planner`) with
Woodpecker CI already in use elsewhere. No `.woodpecker.yml` exists yet in
this repo.
**Desired flow**: PR merges to `main` → pipeline runs lint/typecheck/build
and stops → a human clicks a "Deploy" button to push to staging, and
separately clicks it again to promote to production. Not fully automatic on
merge — deploys to both environments are manual/pushbutton.
**Mechanism (confirmed against current Woodpecker docs)**: Woodpecker has a
built-in **deploy event** for exactly this. Enable **"Allow deployments"** in
the repo's Woodpecker project settings, and any successful pipeline run gets
a **Deploy** button in the UI. Clicking it re-runs that pipeline with
`event: deploy` and a `deploy_to` string you type (e.g. `staging` or
`production`), exposed to steps as `$CI_PIPELINE_DEPLOY_TARGET`. Steps gate
on it, e.g.:
```yaml
steps:
- name: deploy-staging
when:
- event: deploy
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "staging"'
- name: deploy-prod
when:
- event: deploy
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
```
**What needs to exist**:
1. **Two Supabase projects** — staging and prod. This exactly fills the free
tier's "2 active projects" cap (see above) — no free-tier headroom left
for a third project if one is ever wanted later.
2. **Two frontend hosting targets** — two sites/deployments (SaaS host or
self-hosted alongside Gitea/Woodpecker — not yet decided which).
3. **Known wrinkle**: because Vite bakes `VITE_SUPABASE_URL`/`VITE_SUPABASE_ANON_KEY`
in at build time, you can't build once and promote the identical artifact
to both environments — each deploy target needs its own build with its
own env vars baked in. Not hard, just means "deploy" reruns the build
rather than reusing one artifact. A non-issue at this app's scale.
4. **Migrations run per-target** via Supabase CLI, scripted per environment:
```bash
supabase link --project-ref $SUPABASE_PROJECT_REF
supabase db push
supabase functions deploy admin-user-action
```
5. **Secrets needed in Woodpecker** (scoped per target): `SUPABASE_ACCESS_TOKEN`,
staging/prod `SUPABASE_PROJECT_REF`, staging/prod `VITE_SUPABASE_URL` /
`VITE_SUPABASE_ANON_KEY`, plus whatever the frontend host needs (API
token, or SSH key if self-hosted).
6. **Google OAuth**: needs its own authorized redirect URI added in Google
Cloud console for each of the staging and prod domains — one-time setup,
easy to forget.
**Difficulty estimate discussed**: roughly a focused afternoon — nothing here
fights the tooling (no containers to orchestrate, no server process to run).
Breakdown: `.woodpecker.yml` (~1–2 hrs), second Supabase project + migration
check (~30 min), frontend hosting target wiring (~30–60 min), secrets +
"Allow deployments" toggle (~15 min), end-to-end test/debug (~1–2 hrs).
## Open decisions (not yet made)
- Which frontend host: SaaS (Vercel/Netlify/Cloudflare Pages) vs self-hosted
alongside the existing Gitea/Woodpecker infra.
- Where the marketing site (`site/`, see above) lives relative to the app —
leaning subdomain (`diagrav.com` + `app.diagrav.com`, per the links already
in `site/index.html`) over same-domain/different-path, but not finalized —
and whether it gets its own line in the eventual `.woodpecker.yml` or
piggybacks on the frontend deploy step.
- Whether/when to add diagram-snapshot retention (the storage-risk item
above) — not done yet, just identified.
- Actual `.woodpecker.yml` has not been written yet — this doc captures the
plan/shape only, per the user's request to keep this conversation
discussion-only and not make repo changes here.
+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" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AV Planner</title>
<title>Diagrav</title>
</head>
<body>
<div id="root"></div>
+148
View File
@@ -0,0 +1,148 @@
# 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.
## 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.
+108
View File
@@ -8,6 +8,7 @@
"name": "av-planner",
"version": "0.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.115.0",
"@xyflow/react": "^12.11.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -714,6 +715,98 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.115.0",
"@supabase/functions-js": "2.115.0",
"@supabase/postgrest-js": "2.115.0",
"@supabase/realtime-js": "2.115.0",
"@supabase/storage-js": "2.115.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1639,6 +1732,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2158,6 +2260,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+4 -1
View File
@@ -7,9 +7,12 @@
"dev": "vite",
"build": "tsc -b && vite build",
"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": {
"@supabase/supabase-js": "^2.115.0",
"@xyflow/react": "^12.11.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+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

+174
View File
@@ -0,0 +1,174 @@
<!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" href="https://app.diagrav.com/">Log in</a>
<a class="btn btn-primary btn-sm" 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" 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>
</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 { supabase } from './data/supabaseClient'
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-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (!session) {
return <LoginScreen />
}
if (needsUsername === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (needsUsername) {
return <CompleteProfileScreen onDone={() => setNeedsUsername(false)} />
}
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-screen 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-screen 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-screen 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 { 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() {
const project = useProjectStore((s) => s.project)
const bom = useMemo(() => computeBom(project), [project])
const diagram = useDiagramStore((s) => s.diagram)
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 (
<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>
) : (
<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>
{bom.devices.map((line) => (
<tr key={line.key} className="border-b border-slate-100 last:border-0">
@@ -26,6 +56,14 @@ export default function BomPanel() {
)}
</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>
))}
</tbody>
@@ -44,12 +82,29 @@ export default function BomPanel() {
<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">Total length</th>
<th className="pb-1 text-right font-medium">Cost</th>
</tr>
</thead>
<tbody>
{bom.cables.map((line) => (
<tr key={line.cableTypeId} className="border-b border-slate-100 last:border-0">
<td className="py-1 pr-2 font-medium text-slate-700">{line.cableTypeName}</td>
{bom.cables.map((line) => {
const expandable = line.lengths.length > 1
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.totalLength > 0 ? `${line.totalLength}${line.unit}` : '—'}
@@ -59,12 +114,56 @@ export default function BomPanel() {
</span>
)}
</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>
))}
</Fragment>
)
})}
</tbody>
</table>
)}
</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>
)
}
+40 -4
View File
@@ -4,6 +4,26 @@ export type CableEdgeData = {
cableTypeName: string
distance?: number
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'>
@@ -29,8 +49,15 @@ export default function CableEdge({
targetPosition,
})
const isAggregate = !!data?.aggregateCount && data.aggregateCount > 1
const isBuiltIn = !!data?.builtIn
const bundleColor = data?.bundleName ? data.bundleColor : undefined
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 (
@@ -39,13 +66,22 @@ export default function CableEdge({
id={id}
path={edgePath}
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 && (
<EdgeLabelRenderer>
<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"
style={{ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)` }}
className={`nodrag nopan absolute rounded border px-1.5 py-0.5 text-[10px] shadow-sm ${
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}
</div>
+137 -11
View File
@@ -1,6 +1,7 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { Device, Port, PortType } from '../../domain/types'
import { buildHandleId } from './handleIds'
import type { Device, Port, PortDirection, PortType } from '../../domain/types'
import Chevron from '../common/Chevron'
import { buildGroupHandleId, buildHandleId } from './handleIds'
export interface PortInfo {
port: Port
@@ -14,7 +15,14 @@ export type DeviceNodeData = {
/** Resolved from the device's category id — see FlowCanvas — since a
* custom category's id is an opaque uuid, not something to show raw. */
categoryName: string
/** Resolved from the device's manufacturerId, same reasoning as
* categoryName — undefined when no manufacturer is set. */
manufacturerName: string | undefined
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'>
@@ -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 }) {
if (count === 0) return null
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>) {
const { device, categoryName, portInfos } = data
function CompactDeviceNode({ data }: { data: DeviceNodeData }) {
const { device, 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 bg-white shadow-sm transition-shadow ${
selected ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-slate-300'
}`}
>
<div className="rounded-t-lg border-b border-slate-200 bg-slate-50 px-3 py-2">
<div className="w-56 rounded-lg border border-slate-300 bg-white shadow-sm">
<div className="flex items-center gap-1.5 rounded-lg px-3 py-2">
<ExpandToggle expanded={expanded} onToggle={onToggleExpanded} />
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">{device.name}</div>
</div>
{(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-[11px] text-slate-500">
{[device.manufacturer, device.model].filter(Boolean).join(' ') || categoryName}
{[manufacturerName, device.model].filter(Boolean).join(' ') || categoryName}
</div>
</div>
</div>
@@ -131,3 +246,14 @@ export default function DeviceNode({ data, selected }: NodeProps<DeviceFlowNode>
</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>
)
}
+287 -62
View File
@@ -3,6 +3,7 @@ import {
ConnectionMode,
Controls,
MiniMap,
Panel,
ReactFlow,
ReactFlowProvider,
useReactFlow,
@@ -13,41 +14,95 @@ import {
type NodeTypes,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { validateConnection } from '../../domain/compatibility'
import { allCableTypes, allPortTypes, cableTypesForConnection, deviceCategoryName, getPortType } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
import {
allCableTypes,
allPortTypes,
cableTypesForConnection,
deviceCategoryName,
getPortType,
manufacturerName,
} from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import CableEdge, { type CableFlowEdge } from './CableEdge'
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'
const nodeTypes: NodeTypes = { device: DeviceNode }
const edgeTypes: EdgeTypes = { cable: CableEdge }
function FlowCanvasInner() {
const project = useProjectStore((s) => s.project)
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const updateDevicePosition = useProjectStore((s) => s.updateDevicePosition)
const removeDevice = useProjectStore((s) => s.removeDevice)
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()
// A virtual (merged) edge's id carries the real connection ids it stands in
// for, so removing or selecting it can be translated back to the underlying
// domain objects without a parallel id→group lookup table.
const VIRTUAL_EDGE_PREFIX = 'virtual:'
function connectionIdsForEdge(edgeId: string): string[] {
return edgeId.startsWith(VIRTUAL_EDGE_PREFIX) ? edgeId.slice(VIRTUAL_EDGE_PREFIX.length).split(',') : [edgeId]
}
const portTypes = useMemo(() => allPortTypes(project), [project])
const cableTypes = useMemo(() => allCableTypes(project), [project])
// Deterministic per-bundle color (cycling through a fixed palette by the
// 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)
const access = useDiagramStore((s) => s.access)
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
// 'edit') can edit; a view-only collaborator can look but not touch —
// organized-ideas.md §8's per-collaborator permissions. `access` is only
// null in the brief window before the first diagram finishes loading, at
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
const canEdit = access?.myPermission !== 'view'
// 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(
() =>
project.devices.map((device) => {
diagram.devices.map((device) => {
const portInfos: PortInfo[] = device.ports.map((port) => {
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,
).length
const max = portType?.maxConnections ?? 1
@@ -58,65 +113,207 @@ function FlowCanvasInner() {
type: 'device',
position: device.position,
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: {
cableTypeName: cableType?.name ?? 'Unknown Cable',
distance: connection.distance,
unit: cableType?.unit ?? 'ft',
device,
categoryName: deviceCategoryName(catalog, device.category),
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(
(edgeOrConnection: Connection | { source: string | null; sourceHandle?: string | null; target: string | null; targetHandle?: string | null }) => {
const c = edgeOrConnection as Connection
const sourcePortId = parsePortId(c.sourceHandle)
const targetPortId = parsePortId(c.targetHandle)
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false
return validateConnection(project, {
if (!canEdit || !c.source || !c.target || !sourcePortId || !targetPortId) return false
return validateConnection(diagram, catalog, {
sourceDeviceId: c.source,
sourcePortId,
targetDeviceId: c.target,
targetPortId,
}).valid
},
[project],
[diagram, catalog, canEdit],
)
const onConnect = useCallback(
(connection: Connection) => {
if (!canEdit) return
const sourcePortId = parsePortId(connection.sourceHandle)
const targetPortId = parsePortId(connection.targetHandle)
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
const sourceDevice = project.devices.find((d) => d.id === connection.source)
const targetDevice = project.devices.find((d) => d.id === connection.target)
const sourceDevice = diagram.devices.find((d) => d.id === connection.source)
const targetDevice = diagram.devices.find((d) => d.id === connection.target)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
const targetPort = targetDevice?.ports.find((p) => p.id === targetPortId)
const sourcePortType = sourcePort && getPortType(project, sourcePort.portTypeId)
const targetPortType = targetPort && getPortType(project, targetPort.portTypeId)
const sourcePortType = sourcePort && getPortType(catalog, sourcePort.portTypeId)
const targetPortType = targetPort && getPortType(catalog, targetPort.portTypeId)
const defaultCableType =
sourcePortType && targetPortType && cableTypesForConnection(project, sourcePortType, targetPortType)[0]
if (!defaultCableType) return
addConnection({
sourcePortType && targetPortType && cableTypesForConnection(catalog, sourcePortType, targetPortType)[0]
if (!defaultCableType) {
// 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,
sourcePortId,
targetDeviceId: connection.target,
@@ -124,11 +321,12 @@ function FlowCanvasInner() {
cableTypeId: defaultCableType.id,
})
},
[project, addConnection],
[diagram, catalog, canEdit, addConnection],
)
const onNodesChange = useCallback(
(changes: NodeChange<DeviceFlowNode>[]) => {
if (!canEdit) return
for (const change of changes) {
if (change.type === 'position' && change.position) {
updateDevicePosition(change.id, change.position)
@@ -137,33 +335,40 @@ function FlowCanvasInner() {
}
}
},
[updateDevicePosition, removeDevice],
[canEdit, updateDevicePosition, removeDevice],
)
const onEdgesChange = useCallback(
(changes: EdgeChange<CableFlowEdge>[]) => {
if (!canEdit) return
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) => {
if (!event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
const onDragOver = useCallback(
(event: React.DragEvent) => {
if (!canEdit || !event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
},
[canEdit],
)
const onDrop = useCallback(
(event: React.DragEvent) => {
if (!canEdit) return
const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME)
if (!templateId) return
event.preventDefault()
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
addDeviceFromTemplate(templateId, position)
addDeviceFromTemplate(catalog, templateId, position)
},
[screenToFlowPosition, addDeviceFromTemplate],
[screenToFlowPosition, catalog, canEdit, addDeviceFromTemplate],
)
return (
@@ -177,9 +382,15 @@ function FlowCanvasInner() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
nodesDraggable={canEdit}
nodesConnectable={canEdit}
connectionMode={ConnectionMode.Loose}
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={() => {
selectDevice(null)
}}
@@ -189,6 +400,20 @@ function FlowCanvasInner() {
<Background />
<Controls />
<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>
</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
* 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
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 { allDeviceCategories } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
import { allDeviceCategories } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
const NEW_CATEGORY_VALUE = '__new__'
@@ -11,25 +11,32 @@ export default function CategorySelect({
value,
onChange,
className = 'mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm',
publishPublic = false,
}: {
value: string
onChange: (categoryId: string) => void
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 addCustomDeviceCategory = useProjectStore((s) => s.addCustomDeviceCategory)
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceCategory = useCatalogStore((s) => s.addCustomDeviceCategory)
const adminAddDeviceCategory = useCatalogStore((s) => s.adminAddDeviceCategory)
const [creating, setCreating] = useState(false)
const [draftName, setDraftName] = useState('')
const categories = useMemo(
() => [...allDeviceCategories(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allDeviceCategories(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const commitNewCategory = () => {
const commitNewCategory = async () => {
const trimmed = draftName.trim()
if (trimmed) {
onChange(addCustomDeviceCategory(trimmed))
onChange(await (publishPublic ? adminAddDeviceCategory(trimmed) : addCustomDeviceCategory(trimmed)))
}
setCreating(false)
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 { cableTypesForConnection, getPortType } from '../../domain/project'
import { useProjectStore } from '../../state/projectStore'
import { useMemo, useState } from 'react'
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
const project = useProjectStore((s) => s.project)
const updateConnection = useProjectStore((s) => s.updateConnection)
const removeConnection = useProjectStore((s) => s.removeConnection)
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
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 sourceDevice = project.devices.find((d) => d.id === connection?.sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === connection?.targetDeviceId)
const connection = diagram.connections.find((c) => c.id === connectionId)
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)
const availableCableTypes = useMemo(() => {
if (!sourcePort || !targetPort) return []
const sourcePortType = getPortType(project, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId)
return sourcePortType && targetPortType ? cableTypesForConnection(project, sourcePortType, targetPortType) : []
}, [project, sourcePort, targetPort])
const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
const targetPortType = getPortType(catalog, targetPort.portTypeId)
return sourcePortType && targetPortType ? cableTypesForConnection(catalog, sourcePortType, targetPortType) : []
}, [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
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="flex items-center justify-between py-0.5">
<span className="text-slate-500">From</span>
@@ -76,6 +109,30 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
</div>
</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
<input
@@ -96,6 +153,165 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
/>
</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">
<button
onClick={() => removeConnection(connection.id)}
@@ -104,6 +320,6 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
Remove cable
</button>
</div>
</div>
</fieldset>
)
}
+57 -21
View File
@@ -1,33 +1,38 @@
import { useMemo, useState } from 'react'
import { PORT_DIRECTIONS } from '../../domain/constants'
import { allPortTypes } from '../../domain/project'
import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types'
import { useProjectStore } from '../../state/projectStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore'
import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const project = useProjectStore((s) => s.project)
const updateDevice = useProjectStore((s) => s.updateDevice)
const removeDevice = useProjectStore((s) => s.removeDevice)
const addPort = useProjectStore((s) => s.addPort)
const clonePort = useProjectStore((s) => s.clonePort)
const updatePort = useProjectStore((s) => s.updatePort)
const removePort = useProjectStore((s) => s.removePort)
const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const updateDevice = useDiagramStore((s) => s.updateDevice)
const removeDevice = useDiagramStore((s) => s.removeDevice)
const addPort = useDiagramStore((s) => s.addPort)
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 device = project.devices.find((d) => d.id === deviceId)
const device = diagram.devices.find((d) => d.id === deviceId)
const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
if (!device) return null
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 (
<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">
<label className="col-span-2 text-xs font-medium text-slate-600">
Name
@@ -43,13 +48,12 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</label>
<label className="text-xs font-medium text-slate-600">
Manufacturer
<input
value={device.manufacturer ?? ''}
onChange={(e) => updateDevice(device.id, { manufacturer: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
<ManufacturerSelect
value={device.manufacturerId}
onChange={(manufacturerId) => updateDevice(device.id, { manufacturerId })}
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
<label className="text-xs font-medium text-slate-600">
Model
<input
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"
/>
</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">
Notes
<textarea
@@ -74,7 +95,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
{device.ports.map((port) => {
const inUse = connectionCountForPort(port.id) > 0
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
ref={(el) => {
if (el && focusPortId === port.id) {
@@ -108,6 +129,20 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
</option>
))}
</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
onClick={() => setFocusPortId(clonePort(device.id, port.id))}
tabIndex={-1}
@@ -126,6 +161,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
✕
</button>
</div>
</div>
)
})}
</div>
@@ -153,6 +189,6 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
Delete device
</button>
</div>
</div>
</fieldset>
)
}
+33 -6
View File
@@ -1,30 +1,57 @@
import { useEffect } 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 FlowCanvas from '../canvas/FlowCanvas'
import DevicePalette from '../palette/DevicePalette'
import ConnectionErrorBanner from './ConnectionErrorBanner'
import RightPanel from './RightPanel'
import TopBar from './TopBar'
import ViewOnlyBanner from './ViewOnlyBanner'
export default function AppShell() {
const isLoaded = useProjectStore((s) => s.isLoaded)
const loadFromStorage = useProjectStore((s) => s.loadFromStorage)
const isLoaded = useDiagramStore((s) => s.isLoaded)
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(() => {
loadFromStorage()
}, [loadFromStorage])
loadInitialDiagram()
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
// 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-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
return (
<div className="flex h-screen flex-col">
<AnnouncementBanner />
<TopBar />
<div className="flex min-h-0 flex-1">
<DevicePalette />
<main className="relative min-w-0 flex-1">
<ConnectionErrorBanner />
<ViewOnlyBanner />
<FlowCanvas />
</main>
<RightPanel />
@@ -1,9 +1,9 @@
import { useEffect } from 'react'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
export default function ConnectionErrorBanner() {
const error = useProjectStore((s) => s.lastConnectionError)
const clear = useProjectStore((s) => s.clearConnectionError)
const error = useDiagramStore((s) => s.lastConnectionError)
const clear = useDiagramStore((s) => s.clearConnectionError)
useEffect(() => {
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>
)
}
+4 -4
View File
@@ -2,17 +2,17 @@ import { useState } from 'react'
import BomPanel from '../bom/BomPanel'
import ConnectionInspector from '../inspector/ConnectionInspector'
import DeviceInspector from '../inspector/DeviceInspector'
import { useProjectStore } from '../../state/projectStore'
import { useDiagramStore } from '../../state/diagramStore'
type Tab = 'inspector' | 'bom'
export default function RightPanel() {
const selectedDeviceId = useProjectStore((s) => s.selectedDeviceId)
const selectedConnectionId = useProjectStore((s) => s.selectedConnectionId)
const selectedDeviceId = useDiagramStore((s) => s.selectedDeviceId)
const selectedConnectionId = useDiagramStore((s) => s.selectedConnectionId)
const [tab, setTab] = useState<Tab>('inspector')
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-slate-200 bg-white">
<aside className="flex h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white">
<div className="flex border-b border-slate-200">
<button
onClick={() => setTab('inspector')}
+152 -31
View File
@@ -1,23 +1,79 @@
import { useRef } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { useProjectStore } from '../../state/projectStore'
import { useMemo, useRef, useState } from 'react'
import { downloadDiagramFile, readDiagramFile, DiagramImportError } from '../../data/exportImport'
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 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() {
const project = useProjectStore((s) => s.project)
const renameProject = useProjectStore((s) => s.renameProject)
const newProject = useProjectStore((s) => s.newProject)
const importProject = useProjectStore((s) => s.importProject)
const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
const renameDiagram = useDiagramStore((s) => s.renameDiagram)
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 [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 = () => {
if (project.devices.length > 0 || project.connections.length > 0) {
const confirmed = window.confirm('Start a new project? Unsaved changes to the current one will be lost (unless exported).')
if (!confirmed) return
}
newProject()
const pendingSubmissionCount = useMemo(
() => mySubmissions.filter((s) => s.status === 'pending').length,
[mySubmissions],
)
const unseenOutcomeCount = useMemo(() => {
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()
@@ -26,10 +82,10 @@ export default function TopBar() {
event.target.value = ''
if (!file) return
try {
const imported = await readProjectFile(file)
importProject(imported)
const imported = await readDiagramFile(file)
await importDiagram(imported)
} 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}`)
}
}
@@ -37,28 +93,93 @@ export default function TopBar() {
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">AV Planner</span>
<span className="text-sm font-semibold text-indigo-700">Diagrav</span>
<input
value={project.name}
onChange={(e) => renameProject(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"
value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)}
disabled={!canEdit}
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<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
onClick={handleExport}
className="rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
onClick={() => setDiagramManagerOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Export
Diagrams
</button>
<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>
)}
<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>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { useDiagramStore } from '../../state/diagramStore'
/** Shown when the current user only has view access to the open diagram
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and
* inspectors disable their edit controls in this case too (see
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that
* visible instead of leaving it to be discovered by a control not working. */
export default function ViewOnlyBanner() {
const access = useDiagramStore((s) => s.access)
if (access?.myPermission !== 'view') return null
return (
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
View only — you don't have edit access to this diagram.
</div>
</div>
)
}
+233 -51
View File
@@ -1,27 +1,28 @@
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 { 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']
/** Best-effort friendly label for a raw family string: the name of whichever
* 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({
export function CableTypeForm({
initial,
portTypes,
showPublishToggle = false,
onCancel,
onSave,
}: {
initial?: CableType
portTypes: PortType[]
/** See PortTypeForm's own doc — same "new + Admin" -only publish toggle. */
showPublishToggle?: boolean
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 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 [endBPortTypeId, setEndBPortTypeId] = useState(initialEndB?.id ?? portTypes[0]?.id ?? '')
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 handleSave = () => {
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 (
@@ -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.'
: 'Same connector on each end — a regular cable for that connector type.'}
</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">
<button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel
@@ -103,58 +134,116 @@ function CableTypeForm({
}
export default function CableTypesPanel() {
const project = useProjectStore((s) => s.project)
const addCustomCableType = useProjectStore((s) => s.addCustomCableType)
const updateCustomCableType = useProjectStore((s) => s.updateCustomCableType)
const catalog = useCatalogStore((s) => s.catalog)
const addCustomCableType = useCatalogStore((s) => s.addCustomCableType)
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 [editingId, setEditingId] = useState<string | null>(null)
const [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
const portTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const allTypes = useMemo(
() => [...allCableTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allCableTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const customTypes = 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 a = familyLabel(ct.family, portTypes)
const b = familyLabel(ct.family2 ?? ct.family, portTypes)
const a = familyLabel(portTypes, ct.family)
const b = familyLabel(portTypes, ct.family2 ?? ct.family)
return a === b ? a : `${a} ↔ ${b}`
}
const handleCreate = (fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] }) => {
const endA = portTypes.find((pt) => pt.id === fields.endAPortTypeId)
const endB = portTypes.find((pt) => pt.id === fields.endBPortTypeId)
if (!endA || !endB) return
addCustomCableType({
name: fields.name,
family: endA.family,
family2: endA.family === endB.family ? undefined : endB.family,
unit: fields.unit,
})
const handleCreate = (fields: CableTypeFormFields, publishPublic: boolean) => {
const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return
if (isAdmin && publishPublic) adminAddCableType(resolved)
else addCustomCableType(resolved)
setCreating(false)
}
const handleUpdate = (
id: string,
fields: { name: string; endAPortTypeId: string; endBPortTypeId: string; unit: CableType['unit'] },
) => {
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,
})
const handleUpdate = (id: string, fields: CableTypeFormFields) => {
const resolved = resolveCableTypeFields(fields, portTypes)
if (!resolved) return
updateCustomCableType(id, resolved)
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 (
<div className="space-y-4">
<div>
@@ -170,7 +259,12 @@ export default function CableTypesPanel() {
</div>
{creating && (
<div className="mb-1.5">
<CableTypeForm portTypes={portTypes} onCancel={() => setCreating(false)} onSave={handleCreate} />
<CableTypeForm
portTypes={portTypes}
showPublishToggle={isAdmin}
onCancel={() => setCreating(false)}
onSave={handleCreate}
/>
</div>
)}
{customTypes.length === 0 && !creating ? (
@@ -197,14 +291,36 @@ export default function CableTypesPanel() {
<div className="font-medium text-slate-700">{ct.name}</div>
<div className="truncate text-[10px] text-slate-400">
{describeEnds(ct)} · {ct.unit}
{typeof ct.costPerUnit === 'number' && ` · $${ct.costPerUnit.toFixed(2)}/${ct.unit}`}
</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
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
</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>
),
)}
@@ -216,14 +332,80 @@ export default function CableTypesPanel() {
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length})
</h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((ct) => (
<div key={ct.id} className="px-1 py-0.5 text-[11px] text-slate-500">
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((ct) => {
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>
</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>
))}
) : 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>
<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>
)
@@ -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>
)
}
+414 -105
View File
@@ -1,72 +1,169 @@
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 { 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 { 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 DeviceTemplateEditor from './DeviceTemplateEditor'
export default function DevicePalette() {
const project = useProjectStore((s) => s.project)
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
const removeCustomDeviceTemplate = useProjectStore((s) => s.removeCustomDeviceTemplate)
const hideBuiltInDeviceTemplate = useProjectStore((s) => s.hideBuiltInDeviceTemplate)
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())
type EditorTarget =
| { mode: 'new' }
| { mode: 'edit'; template: DeviceTemplate }
| { mode: 'suggestEdit'; template: DeviceTemplate }
| { mode: 'adminEdit'; template: DeviceTemplate }
const groups = useMemo(() => {
const categories = allDeviceCategories(project)
const categoryName = (id: string) => categories.find((c) => c.id === id)?.name ?? id
const byCategory = new Map<string, DeviceTemplate[]>()
for (const template of allDeviceTemplates(project)) {
const list = byCategory.get(template.category) ?? []
list.push(template)
byCategory.set(template.category, list)
/** organized-ideas.md §5: browsable both category→manufacturer and
* manufacturer→category, not a single fixed hierarchy — the two view
* modes swap which dimension is the top-level (collapsible) group and
* which is a plain sub-heading within it. */
type ViewMode = 'category' | 'manufacturer'
/** Sentinel sub/top-group key for devices with no manufacturer set —
* 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) => {
setExpandedCategories((prev) => {
interface TopGroup {
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 access = useDiagramStore((s) => s.access)
const canEdit = access?.myPermission !== 'view'
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)
if (next.has(categoryId)) next.delete(categoryId)
else next.add(categoryId)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const expandCategory = (categoryId: string) => {
setExpandedCategories((prev) => new Set(prev).add(categoryId))
const expandGroup = (key: string) => {
setExpandedGroups((prev) => new Set(prev).add(key))
}
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
if (!canEdit) return
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
event.dataTransfer.effectAllowed = 'move'
}
const handleQuickAdd = (template: DeviceTemplate) => {
if (!canEdit) return
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
const offset = (project.devices.length % 8) * 24
addDeviceFromTemplate(template.id, { x: 80 + offset, y: 80 + offset })
const offset = (diagram.devices.length % 8) * 24
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
}
const handleDelete = (template: DeviceTemplate) => {
if (template.builtIn) {
if (!template.custom) {
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
}
const confirmed = window.confirm(
@@ -75,97 +172,166 @@ export default function DevicePalette() {
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 (
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<div className="flex items-center gap-1.5">
<DropdownMenu label="Library">
<DropdownMenuItem onClick={() => setPortTypesOpen(true)}>Connectors</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCategoryLibraryOpen(true)}>Categories &amp; Manufacturers</DropdownMenuItem>
</DropdownMenu>
<button
onClick={() => setPortTypesOpen(true)}
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')}
onClick={() => setEditorTarget({ mode: 'new' })}
className="rounded bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500"
>
+ Custom
</button>
</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">
Drag a device onto the canvas, or click + to drop it in.
</p>
)}
<div className="flex-1 overflow-y-auto p-2">
{groups.map((group) => {
const expanded = expandedCategories.has(group.categoryId)
{searchResults ? (
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 (
<div key={group.categoryId} className="mb-1">
<div key={group.key} className="mb-1">
<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"
>
<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>
{group.categoryName}
</span>
<span className="text-[10px] text-slate-400">{group.templates.length}</span>
<span className="text-[10px] text-slate-400">{group.count}</span>
</button>
{expanded && (
<ul className="mt-1 space-y-1 pl-2">
{group.templates.map((template) => (
<li key={template.id} className="group">
<div
draggable
onDragStart={(e) => handleDragStart(e, template)}
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"
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{template.name}</div>
<div className="truncate text-[10px] text-slate-400">
{template.ports.length} port{template.ports.length === 1 ? '' : 's'}
{!template.builtIn ? ' · custom' : ''}
<div className="mt-1 space-y-2 pl-2">
{group.subGroups.map((subGroup) => (
<div key={subGroup.key}>
{/* Not independently collapsible, unlike the top-level
group — expanding the category/manufacturer above is
the one click this menu asks for; a second click per
sub-group would be one too many for groups that
usually hold just a couple of devices. */}
<div className="px-1.5 text-[10px] font-medium uppercase tracking-wide text-slate-400">
{subGroup.label}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
{!template.builtIn && (
<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 className="mt-0.5 space-y-1">
{subGroup.templates.map((template) => (
<DeviceRow key={template.id} template={template} {...rowProps} />
))}
</ul>
</div>
))}
</div>
)}
</div>
)
})}
})
)}
</div>
{hiddenDefaults.length > 0 && (
<div className="border-t border-slate-200 px-2 py-1.5">
@@ -176,7 +342,7 @@ export default function DevicePalette() {
<span>
{hiddenListOpen ? 'Hide' : 'Show'} hidden defaults ({hiddenDefaults.length})
</span>
<span className={`inline-block text-[9px] transition-transform ${hiddenListOpen ? 'rotate-90' : ''}`}>▶</span>
<Chevron expanded={hiddenListOpen} className="text-slate-500" />
</button>
{hiddenListOpen && (
<ul className="mt-1 space-y-1">
@@ -184,7 +350,7 @@ export default function DevicePalette() {
<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>
<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"
>
Restore
@@ -197,12 +363,155 @@ export default function DevicePalette() {
)}
{editorTarget && (
<DeviceTemplateEditor
template={editorTarget === 'new' ? undefined : editorTarget}
template={editorTarget.mode === 'new' ? undefined : editorTarget.template}
submissionMode={editorTarget.mode === 'suggestEdit'}
adminMode={editorTarget.mode === 'adminEdit'}
onClose={() => setEditorTarget(null)}
onSaved={(categoryId) => expandCategory(categoryId)}
onSaved={(categoryId, manufacturerId) =>
expandGroup(viewMode === 'category' ? categoryId : (manufacturerId ?? NO_MANUFACTURER_KEY))
}
/>
)}
{portTypesOpen && <ConnectorLibraryModal onClose={() => setPortTypesOpen(false)} />}
{categoryLibraryOpen && <CategoryLibraryModal onClose={() => setCategoryLibraryOpen(false)} />}
</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 items-center justify-between 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>
<div className="ml-2 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100">
{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 { deviceTemplateToRow } from '../../data/catalogRowMapping'
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 { 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 FamilySelect from '../common/FamilySelect'
import ManufacturerSelect from '../common/ManufacturerSelect'
import Modal from '../common/Modal'
interface DraftPort {
@@ -11,6 +16,7 @@ interface DraftPort {
name: string
direction: PortDirection
portTypeId: string
builtInCable?: boolean
}
let draftKeySeq = 0
@@ -21,33 +27,69 @@ function nextDraftKey() {
export default function DeviceTemplateEditor({
template,
submissionMode = false,
adminMode = false,
resubmitId,
onClose,
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
/** 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
/** Called with the template's category id right before closing, so the
* palette can expand that category and reveal what was just created/edited. */
onSaved?: (categoryId: string) => void
/** Called with the template's category and manufacturer ids right before
* closing, so the palette can expand whichever group (category or
* 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 addCustomDeviceTemplate = useProjectStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useProjectStore((s) => s.updateCustomDeviceTemplate)
const addCustomPortType = useProjectStore((s) => s.addCustomPortType)
const catalog = useCatalogStore((s) => s.catalog)
const addCustomDeviceTemplate = useCatalogStore((s) => s.addCustomDeviceTemplate)
const updateCustomDeviceTemplate = useCatalogStore((s) => s.updateCustomDeviceTemplate)
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(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const [name, setName] = useState(template?.name ?? '')
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 [cost, setCost] = useState(template?.cost?.toString() ?? '')
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 ?? '' },
],
)
@@ -57,6 +99,7 @@ export default function DeviceTemplateEditor({
const [newPortTypeCategory, setNewPortTypeCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>('other')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(new Set())
const [focusKey, setFocusKey] = useState<string | null>(null)
const [publishDirectly, setPublishDirectly] = useState(false)
const toggleAlsoCompatible = (portTypeId: string) => {
setAlsoCompatibleWith((prev) => {
@@ -99,12 +142,17 @@ export default function DeviceTemplateEditor({
const handleCreatePortType = () => {
if (!newPortTypeName.trim()) return
const compatibleFamilyIds = expandCompatibleFamilyIds([...alsoCompatibleWith], portTypes)
addCustomPortType({
const fields = {
name: newPortTypeName.trim(),
category: newPortTypeCategory,
family: (newPortTypeFamily.trim() || newPortTypeName.trim()).toLowerCase().replace(/\s+/g, '-'),
...(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('')
setNewPortTypeFamily('')
setAlsoCompatibleWith(new Set())
@@ -118,21 +166,50 @@ export default function DeviceTemplateEditor({
const fields = {
name: name.trim(),
category,
manufacturer: manufacturer.trim() || undefined,
manufacturerId,
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)
} else if (canPublishDirectly && publishDirectly) {
adminAddDeviceTemplate(fields)
} else {
addCustomDeviceTemplate(fields)
}
onSaved?.(category)
onSaved?.(category, manufacturerId)
onClose()
}
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="grid grid-cols-2 gap-3">
<label className="col-span-2 text-xs font-medium text-slate-600">
@@ -146,17 +223,17 @@ export default function DeviceTemplateEditor({
</label>
<label className="text-xs font-medium text-slate-600">
Category
<CategorySelect value={category} onChange={setCategory} />
<CategorySelect value={category} onChange={setCategory} publishPublic={canPublishDirectly && publishDirectly} />
</label>
<label className="text-xs font-medium text-slate-600">
Manufacturer
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
<ManufacturerSelect
value={manufacturerId}
onChange={setManufacturerId}
publishPublic={canPublishDirectly && publishDirectly}
/>
</label>
<label className="col-span-2 text-xs font-medium text-slate-600">
<label className="text-xs font-medium text-slate-600">
Model
<input
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"
/>
</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>
</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>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Ports</h4>
<div className="space-y-1.5">
{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
ref={(el) => {
if (el && focusKey === port.key) {
@@ -205,6 +314,20 @@ export default function DeviceTemplateEditor({
</option>
))}
</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
onClick={() => clonePortRow(port.key)}
tabIndex={-1}
@@ -222,6 +345,7 @@ export default function DeviceTemplateEditor({
✕
</button>
</div>
</div>
))}
{ports.length === 0 && <p className="text-[11px] italic text-slate-400">No ports yet.</p>}
</div>
@@ -258,18 +382,11 @@ export default function DeviceTemplateEditor({
))}
</select>
</div>
<input
value={newPortTypeFamily}
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>
<FamilySelect value={newPortTypeFamily} onChange={setNewPortTypeFamily} portTypes={portTypes} />
<p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>
<div>
<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):
</p>
<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}
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>
</div>
</div>
+259 -38
View File
@@ -1,26 +1,41 @@
import { useMemo, useState } from 'react'
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 { 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,
allTypes,
showPublishToggle = false,
onCancel,
onSave,
}: {
portType: PortType
/** Omitted when defining a brand-new port type — mirrors CableTypeForm's
* optional `initial`. */
portType?: 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
onSave: (fields: Omit<PortType, 'id' | 'custom'>, publishPublic: boolean) => void
}) {
const updateCustomPortType = useProjectStore((s) => s.updateCustomPortType)
const [name, setName] = useState(portType.name)
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType.category)
const [family, setFamily] = useState(portType.family)
const [name, setName] = useState(portType?.name ?? '')
const [category, setCategory] = useState<(typeof PORT_CATEGORIES)[number]['value']>(portType?.category ?? 'other')
const [family, setFamily] = useState(portType?.family ?? '')
const [alsoCompatibleWith, setAlsoCompatibleWith] = useState<Set<string>>(() => {
if (!portType) return new Set()
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))
})
const [publishPublic, setPublishPublic] = useState(false)
const toggle = (id: string) => {
setAlsoCompatibleWith((prev) => {
@@ -31,18 +46,28 @@ function EditCustomPortTypeForm({
})
}
const canSave = name.trim().length > 0
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(
(f) => f !== family.trim(),
(f) => f !== resolvedFamily,
)
updateCustomPortType(portType.id, {
onSave(
{
name: name.trim(),
category,
family: family.trim(),
family: resolvedFamily,
compatibleFamilyIds,
})
onCancel()
maxConnections: portType?.maxConnections,
},
publishPublic,
)
}
return (
@@ -65,17 +90,13 @@ function EditCustomPortTypeForm({
))}
</select>
</div>
<input
value={family}
onChange={(e) => setFamily(e.target.value)}
placeholder="Compatibility family"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<FamilySelect value={family} onChange={setFamily} portTypes={allTypes} />
{!portType && <p className="text-[10px] text-slate-500">Leave this as a new family and it'll default to the connector name.</p>}
<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">
{allTypes
.filter((pt) => pt.id !== portType.id)
.filter((pt) => pt.id !== portType?.id)
.map((pt) => (
<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)} />
@@ -84,13 +105,20 @@ function EditCustomPortTypeForm({
))}
</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">
<button onClick={onCancel} className="text-[11px] text-slate-500">
Cancel
</button>
<button
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
</button>
@@ -100,16 +128,42 @@ function EditCustomPortTypeForm({
}
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 [suggestingId, setSuggestingId] = useState<string | null>(null)
const [adminEditingId, setAdminEditingId] = useState<string | null>(null)
const allTypes = useMemo(
() => [...allPortTypes(project)].sort((a, b) => a.name.localeCompare(b.name)),
[project],
() => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)),
[catalog],
)
const customTypes = 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 families = new Set(pt.compatibleFamilyIds ?? [])
if (families.size === 0) return null
@@ -117,25 +171,107 @@ export default function PortTypesPanel() {
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 (
<div className="space-y-4">
<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})
</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">
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>
) : (
<div className="space-y-1.5">
{customTypes.map((pt) =>
editingId === pt.id ? (
<EditCustomPortTypeForm
<PortTypeForm
key={pt.id}
portType={pt}
allTypes={allTypes}
onCancel={() => setEditingId(null)}
onSave={(fields) => {
updateCustomPortType(pt.id, fields)
setEditingId(null)
}}
/>
) : (
<div
@@ -146,15 +282,36 @@ export default function PortTypesPanel() {
<div className="font-medium text-slate-700">{pt.name}</div>
<div className="truncate text-[10px] text-slate-400">
family: {pt.family}
{describeCompatibility(pt) ? ` · also fits: ${describeCompatibility(pt)}` : ''}
{describeCompatibility(pt) ? ` · can accept: ${describeCompatibility(pt)}` : ''}
</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
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
</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>
),
)}
@@ -166,17 +323,81 @@ export default function PortTypesPanel() {
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Built-in ({builtInTypes.length})
</h4>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((pt) => (
<div key={pt.id} className="px-1 py-0.5 text-[11px] text-slate-500">
<div className="max-h-64 space-y-1 overflow-y-auto rounded border border-slate-200 p-1.5">
{builtInTypes.map((pt) => {
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}
{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>
<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>
)
@@ -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 {
return JSON.stringify(project, null, 2)
export function diagramToJson(diagram: Diagram): string {
return JSON.stringify(diagram, null, 2)
}
export function downloadProjectFile(project: Project): void {
const json = projectToJson(project)
export function downloadDiagramFile(diagram: Diagram): void {
const json = diagramToJson(diagram)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
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.download = `${safeName}.avplan.json`
document.body.appendChild(a)
@@ -18,47 +18,46 @@ export function downloadProjectFile(project: Project): void {
URL.revokeObjectURL(url)
}
class ProjectImportError extends Error {}
class DiagramImportError extends Error {}
/** Minimal structural check — enough to catch "wrong file" without pulling
* 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) {
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>
if (typeof p.id !== 'string' || typeof p.name !== 'string') {
throw new ProjectImportError('File is missing required project fields.')
const d = value as Partial<Diagram>
if (typeof d.id !== 'string' || typeof d.name !== 'string') {
throw new DiagramImportError('File is missing required diagram fields.')
}
if (!Array.isArray(p.devices) || !Array.isArray(p.connections)) {
throw new ProjectImportError('File is missing devices/connections arrays.')
if (!Array.isArray(d.devices) || !Array.isArray(d.connections)) {
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()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new ProjectImportError('File is not valid JSON.')
throw new DiagramImportError('File is not valid JSON.')
}
assertLooksLikeProject(parsed)
return normalizeProject(parsed)
assertLooksLikeDiagram(parsed)
return normalizeDiagram(parsed)
}
/** Fills in collections added after a project may have been saved/exported,
* so older data (from localStorage or an older export file) doesn't crash
* code that expects these arrays to always exist. */
export function normalizeProject(project: Project): Project {
return {
...project,
customDeviceTemplates: project.customDeviceTemplates ?? [],
customPortTypes: project.customPortTypes ?? [],
customCableTypes: project.customCableTypes ?? [],
customDeviceCategories: project.customDeviceCategories ?? [],
hiddenBuiltInDeviceTemplateIds: project.hiddenBuiltInDeviceTemplateIds ?? [],
}
/** Strips any legacy per-diagram catalog fields (customDeviceTemplates,
* customPortTypes, etc.) that might still be present in an older export
* file or a localStorage diagram saved before the catalog moved to
* Supabase (see state/catalogStore.ts) — that data now lives in the shared
* catalog, not the diagram, so it's just discarded here rather than
* crashing on an unexpected shape. */
export function normalizeDiagram(diagram: Diagram): Diagram {
const { id, name, createdAt, updatedAt, devices, connections, bundles } = diagram
// Defaults to [] for any diagram saved before bundles existed — same
// "don't crash on an older shape" contract as the rest of this function.
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,
}
}
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@supabase/supabase-js'
const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!url || !anonKey) {
throw new Error(
'Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Copy .env.example to .env.local and fill them in ' +
'(run `npx supabase start` then `npx supabase status` for local values).',
)
}
export const supabase = createClient(url, anonKey)
+179 -17
View File
@@ -1,5 +1,17 @@
import { allCableTypes } from './project'
import type { Device, Project } from './types'
import { allCableTypes, manufacturerName } from './diagram'
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 {
cableTypeId: string
@@ -9,6 +21,15 @@ export interface BomCableLine {
/** Sum of user-entered distances; connections with no distance are excluded. */
totalLength: 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 {
@@ -18,59 +39,200 @@ export interface BomDeviceLine {
manufacturer?: string
model?: string
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 {
cables: BomCableLine[]
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 {
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 {
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
/** A connection's (or a bundle's — same shape, just its own distance/cost
* 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>()
for (const connection of project.connections) {
const cableType = cableTypes.get(connection.cableTypeId)
/** Every port id, across all devices, with a built-in/captive cable — a
* connection touching either end of one doesn't need a cable purchased
* (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 unit = cableType?.unit ?? 'ft'
const existing = cableLines.get(connection.cableTypeId) ?? {
cableTypeId: connection.cableTypeId,
const existing = cableLines.get(run.cableTypeId) ?? {
cableTypeId: run.cableTypeId,
cableTypeName: name,
unit,
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
lengths: [],
}
existing.count += 1
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
existing.totalLength += connection.distance
const distance = typeof run.distance === 'number' && !Number.isNaN(run.distance) ? run.distance : undefined
if (distance !== undefined) {
existing.totalLength += distance
} else {
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>()
for (const device of project.devices) {
for (const device of diagram.devices) {
const key = deviceGroupKey(device)
const existing = deviceLines.get(key) ?? {
key,
name: device.name,
category: device.category,
manufacturer: device.manufacturer,
manufacturer: manufacturerName(catalog, device.manufacturerId),
model: device.model,
count: 0,
totalCost: 0,
devicesMissingCost: 0,
}
existing.count += 1
if (typeof device.cost === 'number' && !Number.isNaN(device.cost)) {
existing.totalCost += device.cost
} else {
existing.devicesMissingCost += 1
}
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 {
cables: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
cables,
devices,
totalCableCost,
totalDeviceCost,
grandTotalCost: totalCableCost + totalDeviceCost,
}
}
+10 -9
View File
@@ -1,5 +1,5 @@
import { acceptedFamilies, getPortType, hasBridgingCableType } from './project'
import type { Connection, Device, PortType, Project } from './types'
import { acceptedFamilies, getPortType, hasBridgingCableType } from './diagram'
import type { Catalog, Connection, Device, Diagram, PortType } from './types'
/**
* 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.
*/
export function validateConnection(
project: Project,
diagram: Diagram,
catalog: Catalog,
candidate: ConnectionCandidate,
existingConnections: Connection[] = project.connections,
existingConnections: Connection[] = diagram.connections,
): ConnectionValidation {
const { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId } = candidate
@@ -46,8 +47,8 @@ export function validateConnection(
return { valid: false, reason: 'A port cannot connect to itself.' }
}
const sourceDevice = project.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = project.devices.find((d) => d.id === targetDeviceId)
const sourceDevice = diagram.devices.find((d) => d.id === sourceDeviceId)
const targetDevice = diagram.devices.find((d) => d.id === targetDeviceId)
const sourcePort = sourceDevice?.ports.find((p) => p.id === sourcePortId)
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.' }
}
const sourcePortType = getPortType(project, sourcePort.portTypeId)
const targetPortType = getPortType(project, targetPort.portTypeId)
const sourcePortType = getPortType(catalog, sourcePort.portTypeId)
const targetPortType = getPortType(catalog, targetPort.portTypeId)
if (!sourcePortType || !targetPortType) {
return { valid: false, reason: 'Unknown port type.' }
}
// Directly compatible port types, or bridgeable by a specific adapter
// cable (different connectors on each end, e.g. XLR-to-TRS) even when the
// port types themselves declare no general compatibility.
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(project, sourcePortType, targetPortType)) {
if (!portTypesCompatible(sourcePortType, targetPortType) && !hasBridgingCableType(catalog, sourcePortType, targetPortType)) {
return {
valid: false,
reason: `${sourcePortType.name} is not compatible with ${targetPortType.name}.`,
+2 -2
View File
@@ -1,7 +1,7 @@
import type { PortCategory, PortDirection } from './types'
// Device categories are no longer a fixed list — see `domain/project.ts`'s
// `allDeviceCategories` (built-ins + project-custom, merged).
// Device categories are no longer a fixed list — see `domain/diagram.ts`'s
// `allDeviceCategories` (public catalog + your own private ones, merged).
export const PORT_DIRECTIONS: { value: PortDirection; label: string }[] = [
{ value: 'input', label: 'Input' },
+58 -65
View File
@@ -1,58 +1,70 @@
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, Device, DeviceCategoryDef, DeviceTemplate, Port, PortType, Project } from './types'
import type { CableType, Catalog, Device, DeviceCategoryDef, Diagram, DeviceTemplate, Manufacturer, PortType } from './types'
/** Merges built-in + project-custom entries. Custom entries with a matching
* id win, so a project could in principle override a built-in (not exposed
* 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(catalog: Catalog): PortType[] {
return catalog.portTypes
}
export function allPortTypes(project: Project): PortType[] {
return mergeById(BUILT_IN_PORT_TYPES, project.customPortTypes)
export function allCableTypes(catalog: Catalog): CableType[] {
return catalog.cableTypes
}
export function allCableTypes(project: Project): CableType[] {
return mergeById(BUILT_IN_CABLE_TYPES, project.customCableTypes)
/** `hiddenPublicIds` is a per-user UI preference (see state/catalogStore.ts),
* 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[] {
const merged = mergeById(BUILT_IN_DEVICE_TEMPLATES, project.customDeviceTemplates)
const hidden = new Set(project.hiddenBuiltInDeviceTemplateIds)
return merged.filter((t) => !(t.builtIn && hidden.has(t.id)))
/** Public templates the user has hidden, for a "restore" list. */
export function hiddenDeviceTemplates(catalog: Catalog, hiddenPublicIds: ReadonlySet<string>): DeviceTemplate[] {
return catalog.deviceTemplates.filter((t) => !t.custom && hiddenPublicIds.has(t.id))
}
/** Built-in templates the user has hidden, for a "restore" list — deleting a
* custom template is permanent, but hiding a built-in one isn't, since the
* 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(catalog: Catalog): DeviceCategoryDef[] {
return catalog.deviceCategories
}
export function allDeviceCategories(project: Project): DeviceCategoryDef[] {
return mergeById(BUILT_IN_DEVICE_CATEGORIES, project.customDeviceCategories)
}
export function getDeviceCategory(project: Project, categoryId: string): DeviceCategoryDef | undefined {
return allDeviceCategories(project).find((c) => c.id === categoryId)
export function getDeviceCategory(catalog: Catalog, categoryId: string): DeviceCategoryDef | undefined {
return allDeviceCategories(catalog).find((c) => c.id === categoryId)
}
/** 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. */
export function deviceCategoryName(project: Project, categoryId: string): string {
return getDeviceCategory(project, categoryId)?.name ?? categoryId
export function deviceCategoryName(catalog: Catalog, categoryId: string): string {
return getDeviceCategory(catalog, categoryId)?.name ?? categoryId
}
export function getPortType(project: Project, portTypeId: string): PortType | undefined {
return allPortTypes(project).find((pt) => pt.id === portTypeId)
export function allManufacturers(catalog: Catalog): Manufacturer[] {
return catalog.manufacturers
}
export function getCableType(project: Project, cableTypeId: string): CableType | undefined {
return allCableTypes(project).find((ct) => ct.id === cableTypeId)
export function getManufacturer(catalog: Catalog, manufacturerId: string): Manufacturer | undefined {
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
@@ -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
* a plain cable that means its family matches; for an adapter cable it's
* 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)
return allCableTypes(project).filter((ct) => {
return allCableTypes(catalog).filter((ct) => {
const [endA, endB] = cableEnds(ct)
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,
* without changing either port type's own compatibility rules.
*/
export function cableTypesForConnection(
project: Project,
sourcePortType: PortType,
targetPortType: PortType,
): CableType[] {
export function cableTypesForConnection(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): CableType[] {
const sourceFamilies = acceptedFamilies(sourcePortType)
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
* types — used to allow a connection even when the port types aren't
* declared directly compatible, as long as a specific adapter cable exists. */
export function hasBridgingCableType(project: Project, sourcePortType: PortType, targetPortType: PortType): boolean {
return cableTypesForConnection(project, sourcePortType, targetPortType).length > 0
export function hasBridgingCableType(catalog: Catalog, sourcePortType: PortType, targetPortType: PortType): boolean {
return cableTypesForConnection(catalog, sourcePortType, targetPortType).length > 0
}
/**
@@ -141,7 +149,7 @@ export function expandCompatibleFamilyIds(selectedPortTypeIds: string[], allType
return [...families]
}
export function createEmptyProject(name = 'Untitled Project'): Project {
export function createEmptyDiagram(name = 'Untitled Diagram'): Diagram {
const now = new Date().toISOString()
return {
id: uuid(),
@@ -150,41 +158,26 @@ export function createEmptyProject(name = 'Untitled Project'): Project {
updatedAt: now,
devices: [],
connections: [],
customDeviceTemplates: [],
customPortTypes: [],
customCableTypes: [],
customDeviceCategories: [],
hiddenBuiltInDeviceTemplateIds: [],
bundles: [],
}
}
/** Instantiates a device on the canvas from a template, deep-copying ports
* (with fresh ids) so editing the instance never mutates the template. */
export function createDeviceFromTemplate(
template: DeviceTemplate,
position: { x: number; y: number },
): Device {
export function createDeviceFromTemplate(template: DeviceTemplate, position: { x: number; y: number }): Device {
return {
id: uuid(),
name: template.name,
templateId: template.id,
category: template.category,
manufacturer: template.manufacturer,
manufacturerId: template.manufacturerId,
model: template.model,
ports: template.ports.map((port) => ({ ...port, id: uuid() })),
cost: template.cost,
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".
* 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
-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'
/** 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
/** 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 {
id: string
name: string
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
* between two ports is decided by comparing `family` (plus optional
@@ -53,6 +64,14 @@ export interface Port {
name: string
direction: PortDirection
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). */
@@ -60,10 +79,20 @@ export interface DeviceTemplate {
id: string
name: string
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
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
@@ -73,10 +102,16 @@ export interface Device {
name: string
templateId?: string
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
notes?: string
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 }
}
@@ -95,6 +130,9 @@ export interface CableType {
* not just where this particular cable is actually used). */
family2?: string
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
custom?: boolean
}
@@ -110,24 +148,65 @@ export interface Connection {
/** User-entered run length, in the CableType's unit. Optional because
* real-world layout isn't modeled (no floor plan / scale). */
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
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
name: string
createdAt: string
updatedAt: string
devices: Device[]
connections: Connection[]
/** User-defined additions to the built-in libraries, scoped to this project. */
customDeviceTemplates: DeviceTemplate[]
customPortTypes: PortType[]
customCableTypes: CableType[]
customDeviceCategories: DeviceCategoryDef[]
/** Built-in device template ids the user has removed from their palette.
* Hidden rather than deleted since built-ins are shared code, not
* per-project data — this lets them be restored later. */
hiddenBuiltInDeviceTemplateIds: string[]
bundles: CableBundle[]
}
/**
* The full set of port types / cable types / device categories / device
* templates visible to the current user — public entries plus their own
* private ones, already merged (by Postgres RLS, at fetch time — see
* state/catalogStore.ts), not scoped to any one diagram. Devices/ports/
* 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[]
}
+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
+451
View File
@@ -0,0 +1,451 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "av-planner"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs, matching the cloud default. Set to `false` to require explicit GRANTs
# instead. Left unset, a fresh project falls back to `true`.
# auto_expose_new_tables = true
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files, directories, or glob patterns that describe your database.
# Supports paths relative to supabase directory: "./schemas/*.sql", "./database".
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
[storage.vector]
enabled = true
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:5173"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://127.0.0.1:5173"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
# Enabled for organized-ideas.md §2's self-service Google-account migration:
# link a new Google identity to your existing account (ProfileModal), then
# unlink the old one, all while signed in as the account being migrated.
enable_manual_linking = true
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
# Enabled per organized-ideas.md §2: email verification is a hard gate before
# first use for manual signups (Google-SSO users are implicitly verified).
enable_confirmations = true
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./supabase/templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Google SSO (organized-ideas.md §2: username collected up front for manual
# signup, email pulled from Google for this path). Client id/secret come
# from your own Google Cloud OAuth credentials via env var substitution —
# see README/setup notes for how to create them. The CLI auto-loads
# .env/.env.local from the repo root, so set these there, never here.
[auth.external.google]
enabled = true
client_id = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)"
redirect_uri = ""
url = ""
# Required for local sign-in with Google per Supabase's own guidance — the
# local GoTrue instance can't satisfy the nonce check the way a deployed
# instance with a stable public URL can. Revisit (set false) once this is
# ever pointed at a real deployed URL instead of localhost.
skip_nonce_check = true
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# pg-delta is the schema diff engine for db diff / db pull / db remote commit.
# Set enabled = false to fall back to the legacy migra engine.
[experimental.pgdelta]
enabled = true
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./schemas"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
[functions.admin-user-action]
enabled = true
# This function expects a real signed-in user's JWT (auth: 'user' mode in
# index.ts already verifies it via project JWKS) — verify_jwt = true adds a
# cheap gateway-level rejection of missing/malformed tokens before the
# function even runs, on top of that.
verify_jwt = true
import_map = "./functions/admin-user-action/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/admin-user-action/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/admin-user-action/*.html" ]
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
}
}
@@ -0,0 +1,89 @@
// Ban/unban/delete a user account — organized-ideas.md §6, Super-Admin only.
//
// This has to be an Edge Function rather than a Postgres function (unlike
// list_users_for_admin or the other privileged functions in this codebase):
// touching auth.users needs Supabase Auth's Admin API (the stable,
// documented interface for account mutations), not a direct SQL write to
// a schema Supabase manages internally and doesn't guarantee stable
// across upgrades. Ban is reversible (banned_until, no data touched);
// delete cascades to profiles/diagrams via their existing FKs.
//
// To invoke locally (after `supabase start`), with a real user's access
// token in place of ACCESS_TOKEN:
// curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/admin-user-action' \
// --header 'apiKey: <anon key from `supabase status`>' \
// --header 'Authorization: Bearer ACCESS_TOKEN' \
// --header 'Content-Type: application/json' \
// --data '{"action":"ban","userId":"..."}'
import "@supabase/functions-js/edge-runtime.d.ts";
import { withSupabase } from "@supabase/server";
type Action = "ban" | "unban" | "delete";
interface RequestBody {
action: Action;
userId: string;
}
const VALID_ACTIONS: Action[] = ["ban", "unban", "delete"];
// ~100 years — Supabase Auth's own documented idiom for "indefinite ban"
// (there's no literal "forever" value); "none" is the matching unban value.
const PERMANENT_BAN_DURATION = "876000h";
export default {
fetch: withSupabase({ auth: "user" }, async (req, ctx) => {
const callerId = ctx.userClaims!.id;
// Confirm the caller is a Super Admin by reading their own profile
// through the user-scoped (RLS-respecting) client — this leans on the
// same "read your own row" policy every other profile read in the app
// uses, rather than trusting anything in the JWT itself (its `role`
// claim is just "authenticated", not this app's role column).
const { data: callerProfile, error: profileError } = await ctx.supabase
.from("profiles")
.select("role")
.eq("id", callerId)
.single();
if (profileError || callerProfile?.role !== "super_admin") {
return Response.json({ error: "Only a Super Admin can manage user accounts." }, { status: 403 });
}
let body: RequestBody;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid request body." }, { status: 400 });
}
const { action, userId } = body;
if (typeof userId !== "string" || !userId || !VALID_ACTIONS.includes(action)) {
return Response.json({ error: "Request must include a valid action and userId." }, { status: 400 });
}
if (userId === callerId) {
return Response.json({ error: "You cannot perform this action on your own account." }, { status: 400 });
}
if (action === "ban") {
const { error } = await ctx.supabaseAdmin.auth.admin.updateUserById(userId, {
ban_duration: PERMANENT_BAN_DURATION,
});
if (error) return Response.json({ error: error.message }, { status: 500 });
} else if (action === "unban") {
const { error } = await ctx.supabaseAdmin.auth.admin.updateUserById(userId, { ban_duration: "none" });
if (error) return Response.json({ error: error.message }, { status: 500 });
} else {
// Cascades to profiles (and from there, diagrams/owned catalog
// entries) via their existing `on delete cascade` foreign keys —
// the frontend confirmation for this action says so explicitly,
// since it's the one irreversible option here.
const { error } = await ctx.supabaseAdmin.auth.admin.deleteUser(userId);
if (error) return Response.json({ error: error.message }, { status: 500 });
}
return Response.json({ success: true });
}),
};

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