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:
parent
db070f47ee
commit
09eb17605e
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue