Phase 1: mobile responsive foundation
- useMediaQuery hook extracted from CalendarPage inline pattern - h-screen → h-dvh for mobile address bar viewport fix - px-6 → px-4 md:px-6 on all page containers/toolbars (14 files) - Input/Select text-base on mobile to prevent iOS auto-zoom - Sheet full-width on mobile, max-w-[540px] on sm+ - Button icon size touch-friendly (44px mobile, 40px desktop) - Tailwind hoverOnlyWhenSupported: true (fixes 157 hover interactions) - PWA meta tags (apple-mobile-web-app-capable, theme-color) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
36309c2460
commit
1c16df4db0
@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>UMBRA</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@ -21,7 +21,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
@ -39,7 +39,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -23,7 +23,7 @@ export default function AdminDashboardPage() {
|
||||
dashboard ? dashboard.total_users - dashboard.active_users : null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
|
||||
{isLoading ? (
|
||||
|
||||
@ -18,7 +18,7 @@ export default function AdminPortal() {
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Portal header with tab navigation */}
|
||||
<div className="shrink-0 border-b bg-card">
|
||||
<div className="px-6 h-16 flex items-center gap-4">
|
||||
<div className="px-4 md:px-6 h-16 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 mr-6">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-5 w-5 text-red-400" />
|
||||
|
||||
@ -54,7 +54,7 @@ export default function ConfigPage() {
|
||||
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -95,7 +95,7 @@ export default function IAMPage() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@ -161,13 +162,7 @@ export default function CalendarPage() {
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
|
||||
// Track desktop breakpoint to prevent dual EventDetailPanel mount
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(min-width: 1024px)');
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
// Continuously resize calendar during panel open/close CSS transition
|
||||
useEffect(() => {
|
||||
@ -483,7 +478,7 @@ export default function CalendarPage() {
|
||||
|
||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
|
||||
@ -86,13 +86,13 @@ export default function DashboardPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-6">
|
||||
<div className="px-4 md:px-6 py-6">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-8 w-48 rounded bg-muted" />
|
||||
<div className="h-4 w-32 rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
||||
<DashboardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
@ -110,7 +110,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header — greeting + date + quick add */}
|
||||
<div className="px-6 pt-6 pb-2 flex items-center justify-between">
|
||||
<div className="px-4 md:px-6 pt-6 pb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||
{getGreeting(settings?.preferred_name || undefined)}
|
||||
@ -156,7 +156,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
||||
<div className="space-y-5">
|
||||
{/* Week Timeline */}
|
||||
{upcomingData && (
|
||||
|
||||
@ -22,7 +22,7 @@ export default function AppLayout() {
|
||||
<LockProvider>
|
||||
<AlertsProvider>
|
||||
<NotificationProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="flex h-dvh overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {
|
||||
|
||||
@ -285,7 +285,7 @@ export default function LocationsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -320,7 +320,7 @@ export default function LocationsPage() {
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 pb-6 pt-5">
|
||||
<div className="px-4 md:px-6 pb-6 pt-5">
|
||||
{isLoading ? (
|
||||
<EntityTable<Location>
|
||||
columns={columns}
|
||||
|
||||
@ -162,7 +162,7 @@ export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Page header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
|
||||
@ -227,7 +227,7 @@ export default function NotificationsPage() {
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
|
||||
'flex items-start gap-3 px-4 md:px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
|
||||
!notification.is_read && 'bg-card'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -555,7 +555,7 @@ export default function PeoplePage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CategoryFilterBar
|
||||
@ -622,7 +622,7 @@ export default function PeoplePage() {
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Stat bar */}
|
||||
{!isLoading && people.length > 0 && (
|
||||
<div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
|
||||
<div className="px-4 md:px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
|
||||
<div className="flex gap-6 shrink-0">
|
||||
<StatCounter
|
||||
icon={Users}
|
||||
@ -665,7 +665,7 @@ export default function PeoplePage() {
|
||||
|
||||
{/* Pending requests */}
|
||||
{hasRequests && (
|
||||
<div className="px-6 pb-3">
|
||||
<div className="px-4 md:px-6 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
Pending Requests
|
||||
@ -706,7 +706,7 @@ export default function PeoplePage() {
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 pb-6">
|
||||
<div className="px-4 md:px-6 pb-6">
|
||||
{isLoading ? (
|
||||
<EntityTable<Person>
|
||||
columns={columns}
|
||||
|
||||
@ -345,13 +345,13 @@ export default function ProjectDetail() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
|
||||
<ListSkeleton rows={4} />
|
||||
</div>
|
||||
</div>
|
||||
@ -375,7 +375,7 @@ export default function ProjectDetail() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
@ -417,7 +417,7 @@ export default function ProjectDetail() {
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Summary section - scrolls with left panel on small, fixed on large */}
|
||||
<div className="px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
|
||||
<div className="px-4 md:px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
|
||||
{/* Description */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground">{project.description}</p>
|
||||
@ -490,7 +490,7 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
|
||||
{/* Task list header + view controls */}
|
||||
<div className="px-6 pb-3 flex items-center justify-between shrink-0">
|
||||
<div className="px-4 md:px-6 pb-3 flex items-center justify-between shrink-0">
|
||||
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
@ -544,7 +544,7 @@ export default function ProjectDetail() {
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Left panel: task list or kanban */}
|
||||
<div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
|
||||
<div className="px-6 pb-6">
|
||||
<div className="px-4 md:px-6 pb-6">
|
||||
{topLevelTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListChecks}
|
||||
|
||||
@ -70,7 +70,7 @@ export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||
@ -111,7 +111,7 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && projects.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
|
||||
@ -99,7 +99,7 @@ export default function RemindersPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||
@ -148,7 +148,7 @@ export default function RemindersPage() {
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
<div className="px-4 md:px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && reminders.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
|
||||
@ -344,7 +344,7 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Page header — matches Stage 4-5 pages */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-3 shrink-0">
|
||||
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold font-heading">Settings</h1>
|
||||
</div>
|
||||
|
||||
@ -128,7 +128,7 @@ export default function TodosPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
|
||||
|
||||
{/* Priority filter */}
|
||||
@ -183,7 +183,7 @@ export default function TodosPage() {
|
||||
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
<div className="px-4 md:px-6 py-5">
|
||||
{/* Summary stats */}
|
||||
{!isLoading && todos.length > 0 && (
|
||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
||||
|
||||
@ -18,7 +18,7 @@ const buttonVariants = cva(
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
icon: 'h-11 w-11 md:h-10 md:w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -10,7 +10,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
|
||||
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -60,7 +60,7 @@ const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-350',
|
||||
'fixed right-0 top-0 h-full w-full sm:max-w-[540px] transition-transform duration-350',
|
||||
visible ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
|
||||
14
frontend/src/hooks/useMediaQuery.ts
Normal file
14
frontend/src/hooks/useMediaQuery.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
future: { hoverOnlyWhenSupported: true },
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./index.html',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user