feat: add mobile responsiveness, design consistency, and micro-interactions
- Add mobile hamburger menu with slide-out nav drawer (AppLayout) - Make modals responsive: full-width on mobile, slide-up animation - Scratchpad becomes full-screen overlay on mobile with backdrop - Folder sidebar hidden on mobile, opens as slide-over drawer - Tree editor shows "Desktop Required" gate on mobile - Stack action buttons vertically on mobile (sessions, detail pages) - Increase touch targets throughout (buttons, close icons) - Add CSS animations: fade-in, slide-in-left, scale-in, btn-press - Add card hover lift effect and consistent border highlights - Standardize page padding (px-4 py-6 sm:px-6 sm:py-8) - Responsive headings (text-2xl sm:text-3xl) - CustomStepModal goes full-screen on mobile - Tighten auth page spacing on mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,13 +39,13 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-4xl',
|
||||
lg: 'max-w-full sm:max-w-lg',
|
||||
xl: 'max-w-full sm:max-w-4xl',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
@@ -60,19 +60,21 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex max-h-[85vh] w-full flex-col rounded-lg border border-border bg-card shadow-lg',
|
||||
'relative flex w-full flex-col border border-border bg-card shadow-lg',
|
||||
'max-h-[100vh] rounded-t-lg sm:max-h-[85vh] sm:rounded-lg',
|
||||
'animate-scale-in',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors',
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
@@ -83,13 +85,13 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer - Fixed at bottom */}
|
||||
{footer && (
|
||||
<div className="flex-shrink-0 border-t border-border px-6 py-4">
|
||||
<div className="flex-shrink-0 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { ThemeToggle } from '@/components/common/ThemeToggle'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { BrandWordmark } from '@/components/common/BrandWordmark'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -11,12 +13,39 @@ export function AppLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
setMobileMenuOpen(false)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
// Close mobile menu on route change - key-based reset
|
||||
const [prevPath, setPrevPath] = useState(location.pathname)
|
||||
if (prevPath !== location.pathname) {
|
||||
setPrevPath(location.pathname)
|
||||
if (mobileMenuOpen) setMobileMenuOpen(false)
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMobileMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [mobileMenuOpen, handleKeyDown])
|
||||
|
||||
const navItems = [
|
||||
{ path: '/trees', label: 'Trees' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
@@ -29,6 +58,15 @@ export function AppLayout() {
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card backdrop-blur-sm">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground sm:hidden"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<Link to="/trees" className="flex items-center gap-2">
|
||||
<BrandLogo size="sm" />
|
||||
<BrandWordmark size="sm" />
|
||||
@@ -39,7 +77,7 @@ export function AppLayout() {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
'relative rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname.startsWith(item.path)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
@@ -73,7 +111,7 @@ export function AppLayout() {
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'hidden rounded-md px-3 py-1.5 text-sm font-medium sm:block',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -83,8 +121,99 @@ export function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Nav Drawer */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 sm:hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-card shadow-xl animate-slide-in-left">
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
<Link to="/trees" className="flex items-center gap-2">
|
||||
<BrandLogo size="sm" />
|
||||
<BrandWordmark size="sm" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-4">
|
||||
{/* User info */}
|
||||
<div className="mb-4 border-b border-border pb-4">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
{effectiveRole && effectiveRole !== 'engineer' && (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
effectiveRole === 'super_admin' && 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
effectiveRole === 'team_admin' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
effectiveRole === 'viewer' && 'bg-gray-500/10 text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{effectiveRole === 'super_admin' ? 'Super Admin' :
|
||||
effectiveRole === 'team_admin' ? 'Team Admin' :
|
||||
'Viewer'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'block rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
location.pathname.startsWith(item.path)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">Theme</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
'w-full rounded-md px-3 py-2.5 text-left text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<main className="animate-fade-in">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus } from 'lucide-react'
|
||||
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react'
|
||||
import { foldersApi } from '@/api'
|
||||
import type { FolderListItem, FolderTreeItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -9,6 +9,8 @@ interface FolderSidebarProps {
|
||||
onFolderSelect: (folderId: string | null) => void
|
||||
onCreateFolder: (parentId?: string | null) => void
|
||||
onEditFolder: (folder: FolderListItem) => void
|
||||
mobileOpen?: boolean
|
||||
onMobileClose?: () => void
|
||||
}
|
||||
|
||||
// Build tree structure from flat folder list
|
||||
@@ -233,6 +235,8 @@ export function FolderSidebar({
|
||||
onFolderSelect,
|
||||
onCreateFolder,
|
||||
onEditFolder,
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
}: FolderSidebarProps) {
|
||||
const [folders, setFolders] = useState<FolderListItem[]>([])
|
||||
const [folderTree, setFolderTree] = useState<FolderTreeItem[]>([])
|
||||
@@ -349,8 +353,33 @@ export function FolderSidebar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-56 shrink-0 border-r border-border bg-card">
|
||||
{/* Mobile backdrop */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
|
||||
onClick={onMobileClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
'w-56 shrink-0 border-r border-border bg-card',
|
||||
'hidden md:block',
|
||||
mobileOpen && 'fixed inset-y-0 left-0 z-50 block animate-slide-in-left md:relative md:animate-none'
|
||||
)}>
|
||||
<div className="p-4">
|
||||
{/* Mobile close button */}
|
||||
{mobileOpen && (
|
||||
<div className="mb-3 flex items-center justify-between md:hidden">
|
||||
<span className="text-sm font-medium text-card-foreground">Folders</span>
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
|
||||
aria-label="Close folders"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center gap-2 text-sm font-medium text-card-foreground"
|
||||
|
||||
@@ -137,12 +137,22 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile backdrop */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-background/80 backdrop-blur-sm sm:hidden"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed right-2 top-1/2 z-40 -translate-y-1/2',
|
||||
'flex h-[55vh] w-[420px] flex-col',
|
||||
'rounded-lg border border-border bg-card shadow-xl',
|
||||
'fixed z-40',
|
||||
'inset-0 sm:inset-auto sm:right-2 sm:top-1/2 sm:-translate-y-1/2',
|
||||
'flex w-full flex-col sm:h-[55vh] sm:w-[420px]',
|
||||
'border-border bg-card shadow-xl sm:rounded-lg sm:border',
|
||||
'transition-transform duration-200 ease-out',
|
||||
isCollapsed ? 'translate-x-full' : 'translate-x-0'
|
||||
)}
|
||||
|
||||
@@ -64,14 +64,14 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="relative flex h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-background/80 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="relative flex h-[95vh] w-full max-w-full flex-col border border-border bg-card shadow-lg sm:h-[90vh] sm:max-w-4xl sm:rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold">Add Custom Step</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 hover:bg-accent"
|
||||
className="rounded-md p-1.5 hover:bg-accent"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
|
||||
Reference in New Issue
Block a user