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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 13:32:27 -05:00

194 lines
9.0 KiB
TypeScript

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 { useCanEdit } from '../../hooks/useCanEdit'
import AdminUsersModal from '../admin/AdminUsersModal'
import ProfileModal from '../account/ProfileModal'
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
import DiagramSharingModal from '../sharing/DiagramSharingModal'
import VersionHistoryModal from '../sharing/VersionHistoryModal'
import AdminReviewModal from '../submissions/AdminReviewModal'
import MySubmissionsModal from '../submissions/MySubmissionsModal'
import DiagramManagerModal from './DiagramManagerModal'
// When "My Submissions" was last opened — a per-user UI preference (like
// hiddenPublicDeviceTemplateIds in catalogStore), used to badge outcomes
// (approved/rejected) you haven't seen yet without a server-side
// notifications table.
const LAST_SEEN_SUBMISSIONS_KEY = 'av-planner:lastSeenSubmissionsAt'
function getLastSeenSubmissionsAt(): string {
try {
return window.localStorage.getItem(LAST_SEEN_SUBMISSIONS_KEY) ?? ''
} catch {
return ''
}
}
function setLastSeenSubmissionsAt(iso: string): void {
try {
window.localStorage.setItem(LAST_SEEN_SUBMISSIONS_KEY, iso)
} catch {
// Not essential — worst case an already-seen outcome stays badged.
}
}
export default function TopBar() {
const diagram = useDiagramStore((s) => s.diagram)
const canEdit = useCanEdit()
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 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 = () => downloadDiagramFile(diagram)
const handleImportClick = () => fileInputRef.current?.click()
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
try {
const imported = await readDiagramFile(file)
await importDiagram(imported)
} catch (err) {
const message = err instanceof DiagramImportError ? err.message : 'Could not read that file.'
window.alert(`Import failed: ${message}`)
}
}
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-semibold text-indigo-700">Diagrav</span>
<input
value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)}
disabled={!canEdit}
className="w-28 min-w-0 rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:w-48 md:w-64"
/>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setDiagramManagerOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Diagrams
</button>
{/* Editing/reviewing actions below aren't useful on a mobile-width
viewport (view-only there via useCanEdit) — hidden rather than
removed so they come straight back if the window's just narrow,
not actually a phone (see useIsMobile). "Diagrams" above and
"Account" below stay: picking which diagram to look at, and
signing out, are both still things a mobile visitor might want. */}
<div className="hidden md:contents">
<DropdownMenu label="Diagram">
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportClick}>Import</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>Export</DropdownMenuItem>
</DropdownMenu>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
My Submissions
{unseenOutcomeCount > 0 ? (
<span
className="ml-1 rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700"
title="Submission(s) reviewed since you last checked"
>
{unseenOutcomeCount}
</span>
) : (
pendingSubmissionCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{pendingSubmissionCount}
</span>
)
)}
</button>
{isAdmin && (
<DropdownMenu
align="right"
label={
<>
Admin
{adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)}
</>
}
>
<DropdownMenuItem
onClick={() => setAdminReviewOpen(true)}
badge={
adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)
}
>
Review Queue
</DropdownMenuItem>
{isSuperAdmin && <DropdownMenuItem onClick={() => setAdminUsersOpen(true)}>Manage Users</DropdownMenuItem>}
{canManageAnnouncements && (
<DropdownMenuItem onClick={() => setAnnouncementComposerOpen(true)}>Announcement</DropdownMenuItem>
)}
</DropdownMenu>
)}
</div>
<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>
)
}