feat: mobile UI improvements + staging deployment setup

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-19 15:14:05 -05:00
parent db070f47ee
commit 09eb17605e
6 changed files with 597 additions and 74 deletions

31
docker-compose.dev.yml Normal file
View File

@ -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

View File

@ -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:

View File

@ -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<GraphEdge | null>(null);
// Broadcast mode state - tracks who we're following

View File

@ -1459,14 +1459,14 @@ export function Board() {
<PrivateWorkspaceManager />
<VisibilityChangeManager />
</Tldraw>
{/* 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) && (
<AnonymousViewerBanner
onAuthenticated={handleAuthenticated}
triggeredByEdit={showEditPrompt}
/>
)}
*/}
</div>
</LiveImageProvider>
</ConnectionProvider>

View File

@ -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...'
}
>
<span style={{ fontSize: '11px' }}>{config.icon}</span>
<span>{config.label}</span>
<span style={{ fontSize: '12px' }}>{config.icon}</span>
<span style={{ fontWeight: 600 }}>{config.label}</span>
<span style={{ opacity: 0.8, fontSize: '10px' }}> {config.message}</span>
</div>
)
}
@ -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 */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{/* Indexing indicator */}
{isIndexing && (
<span style={{
@ -1790,12 +1813,6 @@ export function MycelialIntelligenceBar() {
}}>
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
</span>
{/* Connection status in expanded header */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{isIndexing && (
<span style={{
color: colors.textMuted,
@ -2178,6 +2195,13 @@ export function MycelialIntelligenceBar() {
</div>
</>
)}
{/* Connection status badge - appears at bottom of MI bar as status update */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
</div>
{/* 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); }
}
`}</style>
</div>
)

View File

@ -46,13 +46,28 @@ const PERMISSION_CONFIG: Record<PermissionLevel, { label: string; color: string;
}
// Custom SharePanel with layout: CryptID -> 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<HTMLButtonElement>(null)
const mobileMenuButtonRef = React.useRef<HTMLButtonElement>(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 = () => (
<div
className="tlui-share-zone"
draggable={false}
style={{
position: 'fixed',
top: '8px',
right: '8px',
pointerEvents: 'all',
zIndex: 1000,
}}
>
{/* Single gear icon for mobile - positioned to match top-left menu */}
<button
ref={mobileMenuButtonRef}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setShowMobileMenu(!showMobileMenu)
setMobileMenuSection('main')
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: '8px',
background: isDarkMode ? '#2d2d2d' : '#f3f4f6',
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
cursor: 'pointer',
color: 'var(--color-text-1)',
transition: 'all 0.15s',
pointerEvents: 'all',
touchAction: 'manipulation',
WebkitTapHighlightColor: 'transparent',
}}
title="Menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
{/* Mobile menu dropdown */}
{showMobileMenu && mobileMenuPos && createPortal(
<>
{/* Backdrop */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99998,
background: 'rgba(0,0,0,0.3)',
}}
onClick={() => {
setShowMobileMenu(false)
setMobileMenuSection('main')
}}
/>
{/* Menu */}
<div
style={{
position: 'fixed',
top: mobileMenuPos.top,
right: Math.max(8, mobileMenuPos.right - 100),
width: 'calc(100vw - 16px)',
maxWidth: '320px',
maxHeight: '70vh',
overflowY: 'auto',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: isDarkMode ? '0 4px 24px rgba(0,0,0,0.6)' : '0 4px 24px rgba(0,0,0,0.2)',
zIndex: 99999,
padding: '8px 0',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Main menu */}
{mobileMenuSection === 'main' && (
<>
{/* Header */}
<div style={{
padding: '8px 16px 12px',
borderBottom: '1px solid var(--color-panel-contrast)',
marginBottom: '4px',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Menu
</span>
</div>
{/* Sign In / Account */}
<button
onClick={() => setMobileMenuSection('signin')}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}>👤</span>
<span>{session.authed ? `@${session.username}` : 'Sign In'}</span>
</span>
<span style={{ color: 'var(--color-text-3)' }}></span>
</button>
{/* Share */}
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}>🔗</span>
<ShareBoardButton className="mobile-menu-item" />
</div>
{/* Star */}
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}></span>
<StarBoardButton className="mobile-menu-item" />
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '8px 0' }} />
{/* Appearance */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '14px', color: 'var(--color-text)' }}>
<span style={{ fontSize: '18px' }}>🎨</span>
<span>Dark Mode</span>
</span>
<button
onClick={handleToggleDarkMode}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: 'pointer',
background: isDarkMode ? '#3b82f6' : '#d1d5db',
position: 'relative',
transition: 'background 0.2s',
}}
>
<div style={{
width: '20px',
height: '20px',
borderRadius: '10px',
background: 'white',
position: 'absolute',
top: '2px',
left: isDarkMode ? '22px' : '2px',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
{/* Settings */}
<button
onClick={() => setMobileMenuSection('settings')}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}></span>
<span>Settings & Permissions</span>
</span>
<span style={{ color: 'var(--color-text-3)' }}></span>
</button>
{/* Keyboard Shortcuts */}
<button
onClick={() => {
setShowMobileMenu(false)
openCommandPalette()
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}></span>
<span>Keyboard Shortcuts</span>
</span>
</button>
{/* API Keys */}
<button
onClick={() => {
setShowMobileMenu(false)
handleManageApiKeys()
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}>🔑</span>
<span>API Keys</span>
</span>
</button>
</>
)}
{/* Sign In Section */}
{mobileMenuSection === 'signin' && (
<>
{/* Back button */}
<button
onClick={() => setMobileMenuSection('main')}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
background: 'none',
border: 'none',
borderBottom: '1px solid var(--color-panel-contrast)',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span></span>
<span style={{ fontWeight: 600 }}>Account</span>
</button>
<div style={{ padding: '16px' }}>
{/* Render CryptIDDropdown which handles its own modal */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CryptIDDropdown isDarkMode={isDarkMode} />
</div>
{session.authed && (
<div style={{ marginTop: '16px', textAlign: 'center' }}>
<p style={{ fontSize: '12px', color: 'var(--color-text-3)', marginBottom: '8px' }}>
Signed in as <strong>@{session.username}</strong>
</p>
{session.email && (
<p style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
{session.email}
</p>
)}
</div>
)}
</div>
</>
)}
{/* Settings Section */}
{mobileMenuSection === 'settings' && (
<>
{/* Back button */}
<button
onClick={() => setMobileMenuSection('main')}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
background: 'none',
border: 'none',
borderBottom: '1px solid var(--color-panel-contrast)',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span></span>
<span style={{ fontWeight: 600 }}>Settings & Permissions</span>
</button>
{/* Permission info */}
<div style={{ padding: '16px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}>
<span style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>Your Permission</span>
<span style={{
fontSize: '11px',
padding: '4px 10px',
borderRadius: '12px',
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
color: PERMISSION_CONFIG[currentPermission].color,
fontWeight: 600,
}}>
{PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label}
</span>
</div>
{/* Request higher permission */}
{session.authed && currentPermission !== 'admin' && (
<button
onClick={() => handleRequestPermission(currentPermission === 'view' ? 'edit' : 'admin')}
disabled={permissionRequestStatus === 'sending'}
style={{
width: '100%',
padding: '10px',
fontSize: '13px',
fontWeight: 500,
fontFamily: 'inherit',
borderRadius: '8px',
border: '1px solid var(--color-primary, #3b82f6)',
background: 'transparent',
color: 'var(--color-primary, #3b82f6)',
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
marginBottom: '12px',
}}
>
{permissionRequestStatus === 'sending' ? 'Sending...' :
permissionRequestStatus === 'sent' ? 'Request Sent!' :
`Request ${currentPermission === 'view' ? 'Edit' : 'Admin'} Access`}
</button>
)}
{/* Board protection toggle for admins */}
{isBoardAdmin && (
<div style={{
padding: '12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
marginTop: '8px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}>
<span style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
🛡 View-only Mode
</span>
<button
onClick={handleToggleProtection}
disabled={protectionLoading}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: protectionLoading ? 'not-allowed' : 'pointer',
background: boardProtected ? '#3b82f6' : '#d1d5db',
position: 'relative',
transition: 'background 0.2s',
}}
>
<div style={{
width: '20px',
height: '20px',
borderRadius: '10px',
background: 'white',
position: 'absolute',
top: '2px',
left: boardProtected ? '22px' : '2px',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
<p style={{ fontSize: '11px', color: 'var(--color-text-3)', margin: 0 }}>
{boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'}
</p>
</div>
)}
</div>
</>
)}
</div>
</>,
document.body
)}
</div>
)
// Return mobile menu on small screens, full menu on larger screens
if (isMobile) {
return <MobileMenu />
}
return (
<div className="tlui-share-zone" draggable={false} style={{ position: 'relative' }}>
{/* Unified menu container - grey oval */}