From 09eb17605e0cecf1a49b443c20fc14f0dc4da5d1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 19 Dec 2025 15:14:05 -0500 Subject: [PATCH] feat: mobile UI improvements + staging deployment setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove anonymous viewer popup (anonymous users can now edit) - Mobile menu consolidation: gear icon with all menus combined - Connection status notifications below MI bar (Offline use, Reconnecting, Live) - Network graph panel starts collapsed on mobile - MI bar positioned at top on mobile Deployment: - Add docker-compose.dev.yml for staging.jeffemmett.com (dev branch) - Update production docker-compose.yml to remove staging route 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker-compose.dev.yml | 31 ++ docker-compose.yml | 7 +- .../networking/NetworkGraphPanel.tsx | 5 +- src/routes/Board.tsx | 4 +- src/ui/MycelialIntelligenceBar.tsx | 154 +++--- src/ui/components.tsx | 470 +++++++++++++++++- 6 files changed, 597 insertions(+), 74 deletions(-) create mode 100644 docker-compose.dev.yml diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..43b1f86 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +# Canvas Website - Dev Branch Deployment +# Automatically deploys from `dev` branch for testing +# Access at: staging.jeffemmett.com + +services: + canvas-dev: + build: + context: . + dockerfile: Dockerfile + args: + - VITE_TLDRAW_WORKER_URL=https://jeffemmett-canvas.jeffemmett.workers.dev + container_name: canvas-dev + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.services.canvas-dev.loadbalancer.server.port=80" + - "traefik.http.routers.canvas-dev.rule=Host(`staging.jeffemmett.com`)" + - "traefik.http.routers.canvas-dev.entrypoints=web" + - "traefik.http.routers.canvas-dev.service=canvas-dev" + networks: + - traefik-public + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + traefik-public: + external: true diff --git a/docker-compose.yml b/docker-compose.yml index cd990b5..478b048 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # Canvas Website Docker Compose # Production: jeffemmett.com, www.jeffemmett.com -# Staging: staging.jeffemmett.com +# Dev branch: staging.jeffemmett.com (separate container via docker-compose.dev.yml) services: canvas-website: @@ -15,16 +15,11 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=traefik-public" - # Single service definition (both routers use same backend) - "traefik.http.services.canvas.loadbalancer.server.port=80" # Production deployment (jeffemmett.com and www) - "traefik.http.routers.canvas-prod.rule=Host(`jeffemmett.com`) || Host(`www.jeffemmett.com`)" - "traefik.http.routers.canvas-prod.entrypoints=web" - "traefik.http.routers.canvas-prod.service=canvas" - # Staging deployment (keep for testing) - - "traefik.http.routers.canvas-staging.rule=Host(`staging.jeffemmett.com`)" - - "traefik.http.routers.canvas-staging.entrypoints=web" - - "traefik.http.routers.canvas-staging.service=canvas" networks: - traefik-public healthcheck: diff --git a/src/components/networking/NetworkGraphPanel.tsx b/src/components/networking/NetworkGraphPanel.tsx index e9f5cfc..29b5084 100644 --- a/src/components/networking/NetworkGraphPanel.tsx +++ b/src/components/networking/NetworkGraphPanel.tsx @@ -160,7 +160,10 @@ interface NetworkGraphPanelProps { export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { const editor = useEditor(); const { session } = useAuth(); - const [isCollapsed, setIsCollapsed] = useState(false); + + // Start collapsed on mobile for less cluttered UI + const isMobile = typeof window !== 'undefined' && window.innerWidth < 640; + const [isCollapsed, setIsCollapsed] = useState(isMobile); const [selectedEdge, setSelectedEdge] = useState(null); // Broadcast mode state - tracks who we're following diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index b1bf488..6e1e6d1 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -1459,14 +1459,14 @@ export function Board() { - {/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */} - {/* Wait for auth to finish loading to avoid flash, then show if not authed or edit triggered */} + {/* Anonymous viewer banner - REMOVED: Anonymous users can now edit freely {!session.loading && (!session.authed || showEditPrompt) && ( )} + */} diff --git a/src/ui/MycelialIntelligenceBar.tsx b/src/ui/MycelialIntelligenceBar.tsx index 9f64460..974ceac 100644 --- a/src/ui/MycelialIntelligenceBar.tsx +++ b/src/ui/MycelialIntelligenceBar.tsx @@ -802,7 +802,7 @@ interface ConversationMessage { executedTransform?: TransformCommand } -// Connection status indicator component - unobtrusive inline display +// Connection status indicator component - appears below MI bar as status update interface ConnectionStatusProps { connectionState: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' isNetworkOnline: boolean @@ -810,46 +810,81 @@ interface ConnectionStatusProps { } function ConnectionStatusBadge({ connectionState, isNetworkOnline, isDark }: ConnectionStatusProps) { - // Don't show anything when fully connected and online - if (connectionState === 'connected' && isNetworkOnline) { - return null - } + const [showLiveStatus, setShowLiveStatus] = useState(false) + const [wasDisconnected, setWasDisconnected] = useState(false) + const prevConnectionState = useRef(connectionState) + const prevNetworkOnline = useRef(isNetworkOnline) + // Track when we transition from disconnected/reconnecting to connected + useEffect(() => { + const wasOffline = !prevNetworkOnline.current || + prevConnectionState.current === 'disconnected' || + prevConnectionState.current === 'reconnecting' || + prevConnectionState.current === 'connecting' + + const isNowConnected = connectionState === 'connected' && isNetworkOnline + + // If we just connected after being disconnected, show "Live" for 5 seconds + if (wasOffline && isNowConnected && wasDisconnected) { + setShowLiveStatus(true) + const timer = setTimeout(() => { + setShowLiveStatus(false) + }, 5000) + return () => clearTimeout(timer) + } + + // Track if we've been disconnected + if (!isNetworkOnline || connectionState === 'disconnected' || connectionState === 'reconnecting') { + setWasDisconnected(true) + } + + prevConnectionState.current = connectionState + prevNetworkOnline.current = isNetworkOnline + }, [connectionState, isNetworkOnline, wasDisconnected]) + + // Determine what to show const getStatusConfig = () => { - if (!isNetworkOnline) { + // Offline use - persistent while offline + if (!isNetworkOnline || connectionState === 'disconnected') { return { icon: '📴', - label: 'Offline', + label: 'Offline use', + message: 'Changes saved locally and will sync on reconnection', color: isDark ? '#a78bfa' : '#8b5cf6', + bgColor: isDark ? 'rgba(139, 92, 246, 0.12)' : 'rgba(139, 92, 246, 0.08)', + borderColor: isDark ? 'rgba(139, 92, 246, 0.25)' : 'rgba(139, 92, 246, 0.15)', pulse: false, } } - switch (connectionState) { - case 'connecting': - return { - icon: '🌱', - label: 'Connecting', - color: '#f59e0b', - pulse: true, - } - case 'reconnecting': - return { - icon: '🔄', - label: 'Reconnecting', - color: '#f59e0b', - pulse: true, - } - case 'disconnected': - return { - icon: '🍄', - label: 'Local', - color: isDark ? '#a78bfa' : '#8b5cf6', - pulse: false, - } - default: - return null + // Reconnecting - while connection is being established + if (connectionState === 'reconnecting' || connectionState === 'connecting') { + return { + icon: '🔄', + label: 'Reconnecting', + message: 'Your changes are safe locally, syncing to live board now', + color: '#f59e0b', + bgColor: isDark ? 'rgba(245, 158, 11, 0.12)' : 'rgba(245, 158, 11, 0.08)', + borderColor: isDark ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.15)', + pulse: true, + } } + + // Live - shows for 5 seconds after connection established + if (connectionState === 'connected' && isNetworkOnline && showLiveStatus) { + return { + icon: '✨', + label: 'Live', + message: 'Your changes synced', + color: '#10b981', + bgColor: isDark ? 'rgba(16, 185, 129, 0.12)' : 'rgba(16, 185, 129, 0.08)', + borderColor: isDark ? 'rgba(16, 185, 129, 0.25)' : 'rgba(16, 185, 129, 0.15)', + pulse: false, + } + } + + // Fully connected and not showing live status - show nothing + return null } const config = getStatusConfig() @@ -860,26 +895,21 @@ function ConnectionStatusBadge({ connectionState, isNetworkOnline, isDark }: Con style={{ display: 'flex', alignItems: 'center', - gap: '4px', - padding: '2px 8px', - borderRadius: '12px', - backgroundColor: isDark ? 'rgba(139, 92, 246, 0.15)' : 'rgba(139, 92, 246, 0.1)', - border: `1px solid ${isDark ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.2)'}`, - fontSize: '10px', + justifyContent: 'center', + gap: '6px', + padding: '4px 12px', + fontSize: '11px', fontWeight: 500, color: config.color, - animation: config.pulse ? 'connectionPulse 2s infinite' : undefined, - flexShrink: 0, + backgroundColor: config.bgColor, + borderTop: `1px solid ${config.borderColor}`, + animation: config.pulse ? 'connectionPulse 2s infinite' : 'fadeIn 0.3s ease', + transition: 'all 0.3s ease', }} - title={!isNetworkOnline - ? 'Working offline - changes saved locally and will sync when reconnected' - : connectionState === 'reconnecting' - ? 'Reconnecting to server - your changes are safe' - : 'Connecting to server...' - } > - {config.icon} - {config.label} + {config.icon} + {config.label} + — {config.message} ) } @@ -1468,9 +1498,9 @@ export function MycelialIntelligenceBar() { className="mycelial-intelligence-bar" style={{ position: 'fixed', - // On mobile: bottom of screen, on desktop: top center - top: isMobile ? 'auto' : '10px', - bottom: isMobile ? '70px' : 'auto', // Above bottom toolbar on mobile + // On mobile: top of screen beneath menus, on desktop: top center + top: isMobile ? '48px' : '10px', // Position below top menus on mobile + bottom: 'auto', left: '50%', transform: 'translateX(-50%)', width: barWidth, @@ -1590,13 +1620,6 @@ export function MycelialIntelligenceBar() { }} /> - {/* Connection status indicator - unobtrusive */} - - {/* Indexing indicator */} {isIndexing && ( ask your mycelial intelligence anything about this workspace - {/* Connection status in expanded header */} - {isIndexing && ( )} + + {/* Connection status badge - appears at bottom of MI bar as status update */} + {/* CSS animations */} @@ -2200,6 +2224,10 @@ export function MycelialIntelligenceBar() { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } + @keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } `} ) diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 86e98b5..3ac90c0 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -46,13 +46,28 @@ const PERMISSION_CONFIG: Record Star -> Gear -> Question mark +// On mobile: Single gear icon that opens consolidated menu function CustomSharePanel() { const { addDialog, removeDialog } = useDialogs() const { session } = useAuth() const { slug } = useParams<{ slug: string }>() const boardId = slug || 'mycofi33' + // Mobile detection + const [isMobile, setIsMobile] = React.useState( + typeof window !== 'undefined' && window.innerWidth < 640 + ) + + // Listen for resize to update mobile state + React.useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 640) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false) + const [showMobileMenu, setShowMobileMenu] = React.useState(false) + const [mobileMenuSection, setMobileMenuSection] = React.useState<'main' | 'signin' | 'share' | 'settings'>('main') // const [showVersionHistory, setShowVersionHistory] = React.useState(false) // TODO: Re-enable when version reversion is ready const [showAISection, setShowAISection] = React.useState(false) const [hasApiKey, setHasApiKey] = React.useState(false) @@ -70,7 +85,9 @@ function CustomSharePanel() { // Refs for dropdown positioning const settingsButtonRef = React.useRef(null) + const mobileMenuButtonRef = React.useRef(null) const [settingsDropdownPos, setSettingsDropdownPos] = React.useState<{ top: number; right: number } | null>(null) + const [mobileMenuPos, setMobileMenuPos] = React.useState<{ top: number; right: number } | null>(null) // Get current permission from session // Authenticated users default to 'edit', unauthenticated to 'view' @@ -131,6 +148,17 @@ function CustomSharePanel() { } }, [showSettingsDropdown]) + // Update mobile menu position when it opens + React.useEffect(() => { + if (showMobileMenu && mobileMenuButtonRef.current) { + const rect = mobileMenuButtonRef.current.getBoundingClientRect() + setMobileMenuPos({ + top: rect.bottom + 8, + right: window.innerWidth - rect.right, + }) + } + }, [showMobileMenu]) + // ESC key handler for closing dropdowns React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -138,14 +166,21 @@ function CustomSharePanel() { e.preventDefault() e.stopPropagation() if (showSettingsDropdown) setShowSettingsDropdown(false) + if (showMobileMenu) { + if (mobileMenuSection !== 'main') { + setMobileMenuSection('main') + } else { + setShowMobileMenu(false) + } + } } } - if (showSettingsDropdown) { + if (showSettingsDropdown || showMobileMenu) { // Use capture phase to intercept before tldraw document.addEventListener('keydown', handleKeyDown, true) } return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [showSettingsDropdown]) + }, [showSettingsDropdown, showMobileMenu, mobileMenuSection]) // Detect dark mode - use state to trigger re-render on change const [isDarkMode, setIsDarkMode] = React.useState( @@ -353,6 +388,437 @@ function CustomSharePanel() { }} /> ) + // Mobile consolidated menu component + const MobileMenu = () => ( +
+ {/* Single gear icon for mobile - positioned to match top-left menu */} + + + {/* Mobile menu dropdown */} + {showMobileMenu && mobileMenuPos && createPortal( + <> + {/* Backdrop */} +
{ + setShowMobileMenu(false) + setMobileMenuSection('main') + }} + /> + {/* Menu */} +
e.stopPropagation()} + > + {/* Main menu */} + {mobileMenuSection === 'main' && ( + <> + {/* Header */} +
+ + Menu + +
+ + {/* Sign In / Account */} + + + {/* Share */} +
+ 🔗 + +
+ + {/* Star */} +
+ + +
+ +
+ + {/* Appearance */} +
+ + 🎨 + Dark Mode + + +
+ + {/* Settings */} + + + {/* Keyboard Shortcuts */} + + + {/* API Keys */} + + + )} + + {/* Sign In Section */} + {mobileMenuSection === 'signin' && ( + <> + {/* Back button */} + +
+ {/* Render CryptIDDropdown which handles its own modal */} +
+ +
+ {session.authed && ( +
+

+ Signed in as @{session.username} +

+ {session.email && ( +

+ {session.email} +

+ )} +
+ )} +
+ + )} + + {/* Settings Section */} + {mobileMenuSection === 'settings' && ( + <> + {/* Back button */} + + + {/* Permission info */} +
+
+ Your Permission + + {PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label} + +
+ + {/* Request higher permission */} + {session.authed && currentPermission !== 'admin' && ( + + )} + + {/* Board protection toggle for admins */} + {isBoardAdmin && ( +
+
+ + 🛡️ View-only Mode + + +
+

+ {boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'} +

+
+ )} +
+ + )} +
+ , + document.body + )} +
+ ) + + // Return mobile menu on small screens, full menu on larger screens + if (isMobile) { + return + } + return (
{/* Unified menu container - grey oval */}