Add mobile view-only support

Below md width, the app now forces view-only (same canEdit mechanism as a
shared view-only diagram) rather than trying to support touch editing:
palette and inspector panels hide, editing controls disable, and the top
bar collapses to just Diagrams + Account. A bottom tab bar swaps between a
full-screen canvas and a full-screen Bill of Materials, since there's no
room for both side by side. Canvas gets a few mobile-specific trims too:
MiniMap and the interactivity lock button hide whenever canEdit is false
(the lock button doesn't actually grant editing either way — nodesDraggable/
nodesConnectable already override it — so leaving it visible would just be
misleading), and h-screen is replaced with h-dvh throughout so the layout
doesn't get clipped by a mobile browser's collapsing address bar.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-28 13:32:27 -05:00
co-authored by Claude Sonnet 5
parent 3344f89a8a
commit 7e37dc5676
15 changed files with 235 additions and 96 deletions
+1
View File
@@ -82,6 +82,7 @@ Stated explicitly during planning: this needs to run **entirely locally** during
- **Recommended:** whatever visual grouping happens in compact view must be purely a canvas rendering concern — the BOM math (cable counts, lengths, totals) always operates on the real underlying connections, never on the aggregated display. Otherwise compact view could silently produce a wrong shopping list. - **Recommended:** whatever visual grouping happens in compact view must be purely a canvas rendering concern — the BOM math (cable counts, lengths, totals) always operates on the real underlying connections, never on the aggregated display. Otherwise compact view could silently produce a wrong shopping list.
- **Implementation detail, still loose:** exactly what "connection-type group" means for aggregation purposes isn't fully pinned down — grouped by cable type, by port family, by direction, some combination? Low-stakes to leave open since this is frontend-only and easy to iterate on visually once it's being built. - **Implementation detail, still loose:** exactly what "connection-type group" means for aggregation purposes isn't fully pinned down — grouped by cable type, by port family, by direction, some combination? Low-stakes to leave open since this is frontend-only and easy to iterate on visually once it's being built.
- **Decided: toggle scope.** Per-device (each device node expands/collapses independently), defaulting to **compact** for a newly-placed device — consistent with the palette categories already defaulting to collapsed. Plus **"Expand all" / "Collapse all"** buttons on the canvas for quickly toggling every device at once, rather than clicking through each one individually. - **Decided: toggle scope.** Per-device (each device node expands/collapses independently), defaulting to **compact** for a newly-placed device — consistent with the palette categories already defaulting to collapsed. Plus **"Expand all" / "Collapse all"** buttons on the canvas for quickly toggling every device at once, rather than clicking through each one individually.
- **Future TODO, not yet built: an "Auto-arrange" layout action.** Surfaced while building mobile view-only support (2026-09-28) — nothing stops two devices' saved positions from overlapping (only quick-add's own placement got a bigger anti-stack grid), and expanding a device grows its node size, which can turn a fine compact layout into an overlapping expanded one. On desktop this was always self-correctable (whoever's looking has edit access and can just drag nodes apart), but a mobile/view-only visitor now has no way to fix it themselves — they're stuck with whatever layout the diagram's owner left. Needs a real graph-layout pass (something like `dagre`), a decision on when it runs (probably a manual button rather than automatic-on-load, so a diagram doesn't silently rearrange itself), and to account for compact vs. expanded node sizing. Deliberately deferred rather than built speculatively — it's a standalone feature, not something to fold into the mobile branch.
## 5. Device Menus ## 5. Device Menus
+2 -2
View File
@@ -32,7 +32,7 @@ export default function App() {
}, [session]) }, [session])
if (session === undefined) { if (session === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div> return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
} }
if (!session) { if (!session) {
@@ -40,7 +40,7 @@ export default function App() {
} }
if (needsUsername === undefined) { if (needsUsername === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div> return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
} }
if (needsUsername) { if (needsUsername) {
@@ -39,7 +39,7 @@ export default function CompleteProfileScreen({ onDone }: { onDone: () => void }
} }
return ( return (
<div className="flex h-screen items-center justify-center bg-slate-50"> <div className="flex h-dvh items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">One more thing</h1> <h1 className="text-sm font-semibold text-indigo-700">One more thing</h1>
<p className="mt-1 text-xs text-slate-500">Choose a username to finish setting up your account.</p> <p className="mt-1 text-xs text-slate-500">Choose a username to finish setting up your account.</p>
+2 -2
View File
@@ -60,7 +60,7 @@ export default function LoginScreen() {
if (confirmSent) { if (confirmSent) {
return ( return (
<div className="flex h-screen items-center justify-center bg-slate-50"> <div className="flex h-dvh items-center justify-center bg-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm"> <div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm">
<h1 className="text-sm font-semibold text-slate-800">Check your email</h1> <h1 className="text-sm font-semibold text-slate-800">Check your email</h1>
<p className="mt-2 text-xs text-slate-500"> <p className="mt-2 text-xs text-slate-500">
@@ -91,7 +91,7 @@ export default function LoginScreen() {
} }
return ( return (
<div className="flex h-screen items-center justify-center bg-slate-50"> <div className="flex h-dvh items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">Diagrav</h1> <h1 className="text-sm font-semibold text-indigo-700">Diagrav</h1>
<p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p> <p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p>
+17 -8
View File
@@ -26,6 +26,8 @@ import {
} from '../../domain/diagram' } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import { useIsMobile } from '../../hooks/useIsMobile'
import CableEdge, { type CableFlowEdge } from './CableEdge' import CableEdge, { type CableFlowEdge } from './CableEdge'
import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode' import DeviceNode, { type DeviceFlowNode, type PortInfo } from './DeviceNode'
import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds' import { buildGroupHandleId, buildHandleId, parsePortId } from './handleIds'
@@ -65,13 +67,11 @@ function FlowCanvasInner() {
const selectConnection = useDiagramStore((s) => s.selectConnection) const selectConnection = useDiagramStore((s) => s.selectConnection)
const { screenToFlowPosition } = useReactFlow() const { screenToFlowPosition } = useReactFlow()
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access)
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as // Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
// 'edit') can edit; a view-only collaborator can look but not touch — // 'edit') can edit; a view-only collaborator, or anyone on a mobile-width
// organized-ideas.md §8's per-collaborator permissions. `access` is only // viewport, can look but not touch — see useCanEdit.
// null in the brief window before the first diagram finishes loading, at const canEdit = useCanEdit()
// which point nothing is rendered yet anyway (AppShell's isLoaded gate). const isMobile = useIsMobile()
const canEdit = access?.myPermission !== 'view'
// Per-device compact/expanded toggle (organized-ideas.md §4) — purely a // Per-device compact/expanded toggle (organized-ideas.md §4) — purely a
// canvas display preference, so it's local component state rather than // canvas display preference, so it's local component state rather than
@@ -398,8 +398,17 @@ function FlowCanvasInner() {
colorMode="light" colorMode="light"
> >
<Background /> <Background />
<Controls /> {/* showInteractive is React Flow's own lock-toggle button, and it's
<MiniMap pannable zoomable className="!bg-white" /> misleading whenever canEdit is already false (shared view-only,
or mobile) — dragging/connecting are gated by nodesDraggable/
nodesConnectable above regardless of this button's state, so
"unlocking" it wouldn't actually grant editing, just click-to-
select. Hiding it avoids implying otherwise. */}
<Controls showInteractive={canEdit} />
{/* Takes a meaningful chunk of a phone-sized viewport for not much
payoff — pan/pinch-zoom plus fitView already gets you oriented
without it. */}
{!isMobile && <MiniMap pannable zoomable className="!bg-white" />}
<Panel position="top-right" className="flex gap-1.5"> <Panel position="top-right" className="flex gap-1.5">
<button <button
onClick={expandAll} onClick={expandAll}
@@ -2,12 +2,12 @@ import { useMemo, useState } from 'react'
import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram' import { allCableTypes, cableTypesForConnection, getPortType } from '../../domain/diagram'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
export default function ConnectionInspector({ connectionId }: { connectionId: string }) { export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access) const canEdit = useCanEdit()
const canEdit = access?.myPermission !== 'view'
const updateConnection = useDiagramStore((s) => s.updateConnection) const updateConnection = useDiagramStore((s) => s.updateConnection)
const removeConnection = useDiagramStore((s) => s.removeConnection) const removeConnection = useDiagramStore((s) => s.removeConnection)
const startBundle = useDiagramStore((s) => s.startBundle) const startBundle = useDiagramStore((s) => s.startBundle)
+2 -2
View File
@@ -4,14 +4,14 @@ import { allPortTypes } from '../../domain/diagram'
import type { PortDirection } from '../../domain/types' import type { PortDirection } from '../../domain/types'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import CategorySelect from '../common/CategorySelect' import CategorySelect from '../common/CategorySelect'
import ManufacturerSelect from '../common/ManufacturerSelect' import ManufacturerSelect from '../common/ManufacturerSelect'
export default function DeviceInspector({ deviceId }: { deviceId: string }) { export default function DeviceInspector({ deviceId }: { deviceId: string }) {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const access = useDiagramStore((s) => s.access) const canEdit = useCanEdit()
const canEdit = access?.myPermission !== 'view'
const updateDevice = useDiagramStore((s) => s.updateDevice) const updateDevice = useDiagramStore((s) => s.updateDevice)
const removeDevice = useDiagramStore((s) => s.removeDevice) const removeDevice = useDiagramStore((s) => s.removeDevice)
const addPort = useDiagramStore((s) => s.addPort) const addPort = useDiagramStore((s) => s.addPort)
+23 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAnnouncementStore } from '../../state/announcementStore' import { useAnnouncementStore } from '../../state/announcementStore'
import { useAuthStore } from '../../state/authStore' import { useAuthStore } from '../../state/authStore'
@@ -6,15 +6,22 @@ import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore' import { useSubmissionStore } from '../../state/submissionStore'
import AnnouncementBanner from '../announcements/AnnouncementBanner' import AnnouncementBanner from '../announcements/AnnouncementBanner'
import BomPanel from '../bom/BomPanel'
import FlowCanvas from '../canvas/FlowCanvas' import FlowCanvas from '../canvas/FlowCanvas'
import DevicePalette from '../palette/DevicePalette' import DevicePalette from '../palette/DevicePalette'
import ConnectionErrorBanner from './ConnectionErrorBanner' import ConnectionErrorBanner from './ConnectionErrorBanner'
import MobileTabBar, { type MobileTab } from './MobileTabBar'
import RightPanel from './RightPanel' import RightPanel from './RightPanel'
import TopBar from './TopBar' import TopBar from './TopBar'
import ViewOnlyBanner from './ViewOnlyBanner' import ViewOnlyBanner from './ViewOnlyBanner'
export default function AppShell() { export default function AppShell() {
const isLoaded = useDiagramStore((s) => s.isLoaded) const isLoaded = useDiagramStore((s) => s.isLoaded)
// Below `md`, RightPanel (and its own Inspector/BOM tabs) isn't shown at
// all — this stands in for just the BOM half, via MobileTabBar. Ignored
// entirely at `md` and up, so it doesn't need to track viewport width
// itself; the two panes' own classNames below do that.
const [mobileTab, setMobileTab] = useState<MobileTab>('diagram')
const loadInitialDiagram = useDiagramStore((s) => s.loadInitialDiagram) const loadInitialDiagram = useDiagramStore((s) => s.loadInitialDiagram)
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded) const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
const loadCatalog = useCatalogStore((s) => s.loadCatalog) const loadCatalog = useCatalogStore((s) => s.loadCatalog)
@@ -40,22 +47,35 @@ export default function AppShell() {
}, [role, loadAdminQueue]) }, [role, loadAdminQueue])
if (!isLoaded || !isCatalogLoaded) { if (!isLoaded || !isCatalogLoaded) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div> return <div className="flex h-dvh items-center justify-center text-sm text-slate-400">Loading…</div>
} }
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-dvh flex-col">
<AnnouncementBanner /> <AnnouncementBanner />
<TopBar /> <TopBar />
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<DevicePalette /> <DevicePalette />
<main className="relative min-w-0 flex-1"> <main className="relative min-w-0 flex-1">
<ConnectionErrorBanner /> <ConnectionErrorBanner />
{/* Both panes stay mounted so switching tabs on mobile doesn't
reset canvas pan/zoom or expanded-device state — only which
one is visible changes. At `md` and up, the diagram pane is
always the one shown (RightPanel has its own BOM tab there),
regardless of mobileTab. ViewOnlyBanner lives inside this pane,
not as a shared sibling — it's about canvas editing, so on
mobile it shouldn't float over the BOM pane too. */}
<div className={mobileTab === 'diagram' ? 'h-full' : 'hidden md:block md:h-full'}>
<ViewOnlyBanner /> <ViewOnlyBanner />
<FlowCanvas /> <FlowCanvas />
</div>
<div className={`h-full overflow-y-auto md:hidden ${mobileTab === 'bom' ? 'block' : 'hidden'}`}>
<BomPanel />
</div>
</main> </main>
<RightPanel /> <RightPanel />
</div> </div>
<MobileTabBar tab={mobileTab} onChange={setMobileTab} />
</div> </div>
) )
} }
+28
View File
@@ -0,0 +1,28 @@
export type MobileTab = 'diagram' | 'bom'
/** Bottom nav for the mobile view-only layout (AppShell) — stands in for
* the palette + RightPanel that only fit at `md` and up. Just two views:
* there's no editing to switch an Inspector tab for on mobile (see
* useCanEdit), so "Diagram" and "Bill of Materials" are the whole set. */
export default function MobileTabBar({ tab, onChange }: { tab: MobileTab; onChange: (tab: MobileTab) => void }) {
return (
<nav className="flex h-12 shrink-0 border-t border-slate-200 bg-white md:hidden">
<button
onClick={() => onChange('diagram')}
className={`flex-1 text-xs font-semibold uppercase tracking-wide ${
tab === 'diagram' ? 'text-indigo-700' : 'text-slate-400'
}`}
>
Diagram
</button>
<button
onClick={() => onChange('bom')}
className={`flex-1 text-xs font-semibold uppercase tracking-wide ${
tab === 'bom' ? 'text-indigo-700' : 'text-slate-400'
}`}
>
Bill of Materials
</button>
</nav>
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function RightPanel() {
const [tab, setTab] = useState<Tab>('inspector') const [tab, setTab] = useState<Tab>('inspector')
return ( return (
<aside className="flex h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white"> <aside className="hidden h-full w-96 shrink-0 flex-col border-l border-slate-200 bg-white md:flex">
<div className="flex border-b border-slate-200"> <div className="flex border-b border-slate-200">
<button <button
onClick={() => setTab('inspector')} onClick={() => setTab('inspector')}
+13 -5
View File
@@ -5,6 +5,7 @@ import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore' import { useAuthStore } from '../../state/authStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore' import { useSubmissionStore } from '../../state/submissionStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import AdminUsersModal from '../admin/AdminUsersModal' import AdminUsersModal from '../admin/AdminUsersModal'
import ProfileModal from '../account/ProfileModal' import ProfileModal from '../account/ProfileModal'
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal' import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
@@ -37,8 +38,7 @@ function setLastSeenSubmissionsAt(iso: string): void {
export default function TopBar() { export default function TopBar() {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access) const canEdit = useCanEdit()
const canEdit = access?.myPermission !== 'view'
const renameDiagram = useDiagramStore((s) => s.renameDiagram) const renameDiagram = useDiagramStore((s) => s.renameDiagram)
const importDiagram = useDiagramStore((s) => s.importDiagram) const importDiagram = useDiagramStore((s) => s.importDiagram)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
@@ -92,13 +92,13 @@ export default function TopBar() {
return ( return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3"> <header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">Diagrav</span> <span className="shrink-0 text-sm font-semibold text-indigo-700">Diagrav</span>
<input <input
value={diagram.name} value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)} onChange={(e) => renameDiagram(e.target.value)}
disabled={!canEdit} disabled={!canEdit}
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" className="w-28 min-w-0 rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:w-48 md:w-64"
/> />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -108,6 +108,13 @@ export default function TopBar() {
> >
Diagrams Diagrams
</button> </button>
{/* Editing/reviewing actions below aren't useful on a mobile-width
viewport (view-only there via useCanEdit) — hidden rather than
removed so they come straight back if the window's just narrow,
not actually a phone (see useIsMobile). "Diagrams" above and
"Account" below stay: picking which diagram to look at, and
signing out, are both still things a mobile visitor might want. */}
<div className="hidden md:contents">
<DropdownMenu label="Diagram"> <DropdownMenu label="Diagram">
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem> <DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem> <DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
@@ -166,6 +173,7 @@ export default function TopBar() {
)} )}
</DropdownMenu> </DropdownMenu>
)} )}
</div>
<DropdownMenu label={username ?? 'Account'} align="right"> <DropdownMenu label={username ?? 'Account'} align="right">
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem> <DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
+24 -9
View File
@@ -1,18 +1,33 @@
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useIsMobile } from '../../hooks/useIsMobile'
/** Shown when the current user only has view access to the open diagram /** Shown whenever the canvas/inspectors have disabled their edit controls
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and * (see useCanEdit) — either because this collaborator only has view access
* inspectors disable their edit controls in this case too (see * to the diagram (organized-ideas.md §8's per-collaborator permissions), or
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that * because the viewport is mobile-width, where editing isn't a realistic
* visible instead of leaving it to be discovered by a control not working. */ * touch interaction. Distinguishes the two messages: an owner viewing their
* own diagram on their phone does have edit access, just not here. */
export default function ViewOnlyBanner() { export default function ViewOnlyBanner() {
const access = useDiagramStore((s) => s.access) const access = useDiagramStore((s) => s.access)
if (access?.myPermission !== 'view') return null const isMobile = useIsMobile()
const sharedViewOnly = access?.myPermission === 'view'
if (!sharedViewOnly && !isMobile) return null
return ( return (
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center"> <div
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm"> className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center px-3 ${
View only — you don't have edit access to this diagram. // On mobile this centered banner is wide enough to reach the
// Expand-all/Collapse-all buttons (FlowCanvas's top-right Panel) —
// there's no room to dodge them sideways, so it sits a row lower
// there instead. Desktop's canvas is wide enough that top-3 never
// reaches that corner.
isMobile ? 'top-14' : 'top-3'
}`}
>
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-center text-xs text-slate-600 shadow-sm">
{sharedViewOnly
? "View only — you don't have edit access to this diagram."
: 'View only on mobile — switch to a larger screen to edit.'}
</div> </div>
</div> </div>
) )
+20 -6
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore' import { useCatalogStore } from '../../state/catalogStore'
import { useDiagramStore } from '../../state/diagramStore' import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore' import { useSubmissionStore } from '../../state/submissionStore'
import { useCanEdit } from '../../hooks/useCanEdit'
import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas' import { TEMPLATE_DRAG_MIME } from '../canvas/FlowCanvas'
import Chevron from '../common/Chevron' import Chevron from '../common/Chevron'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu' import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
@@ -55,8 +56,7 @@ function sortGroupsByLabel<T extends { key: string; label: string }>(groups: T[]
export default function DevicePalette() { export default function DevicePalette() {
const diagram = useDiagramStore((s) => s.diagram) const diagram = useDiagramStore((s) => s.diagram)
const access = useDiagramStore((s) => s.access) const canEdit = useCanEdit()
const canEdit = access?.myPermission !== 'view'
const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate) const addDeviceFromTemplate = useDiagramStore((s) => s.addDeviceFromTemplate)
const catalog = useCatalogStore((s) => s.catalog) const catalog = useCatalogStore((s) => s.catalog)
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds) const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
@@ -153,9 +153,23 @@ export default function DevicePalette() {
const handleQuickAdd = (template: DeviceTemplate) => { const handleQuickAdd = (template: DeviceTemplate) => {
if (!canEdit) return if (!canEdit) return
// Cascade placement so repeated quick-adds don't stack exactly on top of each other. // Tiled placement so repeated quick-adds land visibly apart rather than
const offset = (diagram.devices.length % 8) * 24 // overlapping — a compact device node is ~224px wide, expanded ~288px,
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset }) // so the step sizes below clear both. Wraps into a new row rather than
// an ever-longer diagonal, so a lot of quick-adds still stay reachable
// without much panning. This only governs *new* devices' starting
// position — it's not a general anti-overlap layout (see
// organized-ideas.md's auto-arrange discussion for that).
const QUICK_ADD_GRID_COLS = 4
const QUICK_ADD_STEP_X = 320
const QUICK_ADD_STEP_Y = 220
const index = diagram.devices.length
const col = index % QUICK_ADD_GRID_COLS
const row = Math.floor(index / QUICK_ADD_GRID_COLS)
addDeviceFromTemplate(catalog, template.id, {
x: 80 + col * QUICK_ADD_STEP_X,
y: 80 + row * QUICK_ADD_STEP_Y,
})
} }
const handleDelete = (template: DeviceTemplate) => { const handleDelete = (template: DeviceTemplate) => {
@@ -236,7 +250,7 @@ export default function DevicePalette() {
} }
return ( return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50"> <aside className="hidden h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50 md:flex">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2"> <div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2> <h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
+18
View File
@@ -0,0 +1,18 @@
import { useDiagramStore } from '../state/diagramStore'
import { useIsMobile } from './useIsMobile'
/**
* Whether the current user can edit the open diagram right now. False for a
* view-only collaborator (organized-ideas.md §8's per-collaborator
* permissions) — and, independently, on a mobile-width viewport, where
* drag-and-drop from the palette and precise port-to-port wiring aren't a
* realistic touch interaction. See ViewOnlyBanner for the matching message.
* `access` is only null in the brief window before the first diagram
* finishes loading, at which point nothing is rendered yet anyway
* (AppShell's isLoaded gate).
*/
export function useCanEdit(): boolean {
const access = useDiagramStore((s) => s.access)
const isMobile = useIsMobile()
return access?.myPermission !== 'view' && !isMobile
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
// Matches the `md` breakpoint everywhere else in the layout (AppShell,
// DevicePalette, RightPanel) so "mobile" here means exactly "the width
// those panels disappear at" — not an independently-tuned value.
const QUERY = '(max-width: 767px)'
function getIsMobile(): boolean {
return typeof window !== 'undefined' && window.matchMedia(QUERY).matches
}
/** Live viewport-width check (not a one-time device/UA sniff) — updates on
* rotation or a resized window, which matters for a resizable desktop
* browser window as much as an actual phone. */
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(getIsMobile)
useEffect(() => {
const mql = window.matchMedia(QUERY)
const onChange = () => setIsMobile(mql.matches)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}, [])
return isMobile
}