Compare commits

...

3 Commits

Author SHA1 Message Date
09c35752c6 Add mobile card view to EntityTable with renderers for People and Locations
- EntityTable: add useMediaQuery hook, mobileCardRender prop, and mobile card path
  that replaces the table on screens <768px when a renderer is provided
- PeoplePage: add mobileCardRender showing name, category, email, phone
- LocationsPage: add mobileCardRender showing name, category, address

Note: TodosPage and RemindersPage use custom list components (TodoList,
ReminderList), not EntityTable directly — no changes needed there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:58 +08:00
d0477b1c13 Phase 2: toolbar responsive patterns
- All page toolbars now flex-wrap on mobile with min-h instead of fixed h-16
- Segmented button filters (priority, status, view) hidden on mobile, replaced
  with compact Select dropdowns
- Search inputs hidden on mobile where CategoryFilterBar already has search
- CategoryFilterBar wraps to full-width row on mobile (order-last)
- Action buttons show icon-only on mobile, full text on md+
- Calendar title hidden on xs screens for space
- Desktop layout completely unchanged (md:flex-nowrap restores original)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:56:08 +08:00
1c16df4db0 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>
2026-03-07 16:51:53 +08:00
24 changed files with 184 additions and 71 deletions

View File

@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>UMBRA</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -21,7 +21,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( 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 className="text-muted-foreground">Loading...</div>
</div> </div>
); );
@ -39,7 +39,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( 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 className="text-muted-foreground">Loading...</div>
</div> </div>
); );

View File

@ -23,7 +23,7 @@ export default function AdminDashboardPage() {
dashboard ? dashboard.total_users - dashboard.active_users : null; dashboard ? dashboard.total_users - dashboard.active_users : null;
return ( 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 */} {/* Stats grid */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5"> <div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
{isLoading ? ( {isLoading ? (

View File

@ -18,7 +18,7 @@ export default function AdminPortal() {
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Portal header with tab navigation */} {/* Portal header with tab navigation */}
<div className="shrink-0 border-b bg-card"> <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="flex items-center gap-2 mr-6">
<div className="p-1.5 rounded-md bg-red-500/10"> <div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-5 w-5 text-red-400" /> <ShieldCheck className="h-5 w-5 text-red-400" />

View File

@ -54,7 +54,7 @@ export default function ConfigPage() {
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return ( 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> <Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3"> <CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -95,7 +95,7 @@ export default function IAMPage() {
: null; : null;
return ( 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 */} {/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -15,6 +16,7 @@ import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import CalendarSidebar from './CalendarSidebar'; import CalendarSidebar from './CalendarSidebar';
import EventDetailPanel from './EventDetailPanel'; import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel'; import type { CreateDefaults } from './EventDetailPanel';
@ -161,13 +163,7 @@ export default function CalendarPage() {
const panelOpen = panelMode !== 'closed'; const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount // Track desktop breakpoint to prevent dual EventDetailPanel mount
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches); const isDesktop = useMediaQuery('(min-width: 1024px)');
useEffect(() => {
const mql = window.matchMedia('(min-width: 1024px)');
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
// Continuously resize calendar during panel open/close CSS transition // Continuously resize calendar during panel open/close CSS transition
useEffect(() => { useEffect(() => {
@ -483,7 +479,7 @@ export default function CalendarPage() {
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */} {/* 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 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@ -496,7 +492,17 @@ export default function CalendarPage() {
Today Today
</Button> </Button>
<div className="flex items-center rounded-md border border-border overflow-hidden"> <Select
value={currentView}
onChange={(e) => changeView(e.target.value as CalendarView)}
className="h-8 text-sm w-auto md:hidden"
>
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<option key={view} value={view}>{label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button <button
key={view} key={view}
@ -516,12 +522,12 @@ export default function CalendarPage() {
))} ))}
</div> </div>
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2> <h2 className="text-lg font-semibold font-heading hidden sm:block">{calendarTitle}</h2>
<div className="flex-1" /> <div className="flex-1" />
{/* Event search */} {/* Event search */}
<div className="relative"> <div className="relative hidden md:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search events..." placeholder="Search events..."
@ -558,8 +564,7 @@ export default function CalendarPage() {
</div> </div>
<Button size="sm" onClick={handleCreateNew}> <Button size="sm" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Create Event</span>
Create Event
</Button> </Button>
</div> </div>

View File

@ -86,13 +86,13 @@ export default function DashboardPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full"> <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="animate-pulse space-y-2">
<div className="h-8 w-48 rounded bg-muted" /> <div className="h-8 w-48 rounded bg-muted" />
<div className="h-4 w-32 rounded bg-muted" /> <div className="h-4 w-32 rounded bg-muted" />
</div> </div>
</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 /> <DashboardSkeleton />
</div> </div>
</div> </div>
@ -110,7 +110,7 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */} {/* 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> <div>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in"> <h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)} {getGreeting(settings?.preferred_name || undefined)}
@ -156,7 +156,7 @@ export default function DashboardPage() {
</div> </div>
</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"> <div className="space-y-5">
{/* Week Timeline */} {/* Week Timeline */}
{upcomingData && ( {upcomingData && (

View File

@ -22,7 +22,7 @@ export default function AppLayout() {
<LockProvider> <LockProvider>
<AlertsProvider> <AlertsProvider>
<NotificationProvider> <NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-dvh overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
onToggle={() => { onToggle={() => {

View File

@ -285,10 +285,10 @@ export default function LocationsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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 min-h-[4rem] flex items-center gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
categories={orderedCategories} categories={orderedCategories}
activeFilters={activeFilters} activeFilters={activeFilters}
@ -305,8 +305,7 @@ export default function LocationsPage() {
</div> </div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location"> <Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Location</span>
Add Location
</Button> </Button>
</div> </div>
@ -320,7 +319,7 @@ export default function LocationsPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' 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 ? ( {isLoading ? (
<EntityTable<Location> <EntityTable<Location>
columns={columns} columns={columns}
@ -357,6 +356,17 @@ export default function LocationsPage() {
sortDir={sortDir} sortDir={sortDir}
onSort={handleSort} onSort={handleSort}
visibilityMode={visibilityMode} visibilityMode={visibilityMode}
mobileCardRender={(location) => (
<div className={`rounded-lg border p-3 transition-colors ${selectedLocationId === location.id ? 'border-accent/40 bg-accent/5' : 'border-border bg-card hover:bg-card-elevated'}`}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1">{location.name}</span>
{location.category && <span className="text-[10px] text-muted-foreground">{location.category}</span>}
</div>
{location.address && (
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
)}
</div>
)}
/> />
)} )}
</div> </div>

View File

@ -162,7 +162,7 @@ export default function NotificationsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header */} {/* 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"> <div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" /> <Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1> <h1 className="text-xl font-semibold font-heading">Notifications</h1>
@ -227,7 +227,7 @@ export default function NotificationsPage() {
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={cn( 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' !notification.is_read && 'bg-card'
)} )}
> >

View File

@ -555,9 +555,9 @@ export default function PeoplePage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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 min-h-[4rem] flex items-center gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
activeFilters={activeFilters} activeFilters={activeFilters}
pinnedLabel="Favourites" pinnedLabel="Favourites"
@ -587,8 +587,7 @@ export default function PeoplePage() {
aria-label="Add person" aria-label="Add person"
className="rounded-r-none" className="rounded-r-none"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Person</span>
Add Person
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -622,7 +621,7 @@ export default function PeoplePage() {
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{/* Stat bar */} {/* Stat bar */}
{!isLoading && people.length > 0 && ( {!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"> <div className="flex gap-6 shrink-0">
<StatCounter <StatCounter
icon={Users} icon={Users}
@ -665,7 +664,7 @@ export default function PeoplePage() {
{/* Pending requests */} {/* Pending requests */}
{hasRequests && ( {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"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium"> <span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests Pending Requests
@ -706,7 +705,7 @@ export default function PeoplePage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`} }`}
> >
<div className="px-6 pb-6"> <div className="px-4 md:px-6 pb-6">
{isLoading ? ( {isLoading ? (
<EntityTable<Person> <EntityTable<Person>
columns={columns} columns={columns}
@ -745,6 +744,18 @@ export default function PeoplePage() {
sortDir={sortDir} sortDir={sortDir}
onSort={handleSort} onSort={handleSort}
visibilityMode={visibilityMode} visibilityMode={visibilityMode}
mobileCardRender={(person) => (
<div className={`rounded-lg border p-3 transition-colors ${selectedPersonId === person.id ? 'border-accent/40 bg-accent/5' : 'border-border bg-card hover:bg-card-elevated'}`}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1">{person.name}</span>
{person.category && <span className="text-[10px] text-muted-foreground">{person.category}</span>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{person.email && <span className="truncate">{person.email}</span>}
{person.phone && <span>{person.phone}</span>}
</div>
</div>
)}
/> />
)} )}
</div> </div>

View File

@ -345,13 +345,13 @@ export default function ProjectDetail() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <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 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1> <h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1>
</div> </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} /> <ListSkeleton rows={4} />
</div> </div>
</div> </div>
@ -375,7 +375,7 @@ export default function ProjectDetail() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
@ -396,8 +396,7 @@ export default function ProjectDetail() {
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} /> <Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}> <Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}>
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
Edit
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -409,15 +408,14 @@ export default function ProjectDetail() {
}} }}
disabled={deleteProjectMutation.isPending} disabled={deleteProjectMutation.isPending}
> >
<Trash2 className="mr-2 h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
Delete
</Button> </Button>
</div> </div>
{/* Content area */} {/* Content area */}
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{/* Summary section - scrolls with left panel on small, fixed on large */} {/* 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 */} {/* Description */}
{project.description && ( {project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p> <p className="text-sm text-muted-foreground">{project.description}</p>
@ -490,7 +488,7 @@ export default function ProjectDetail() {
</div> </div>
{/* Task list header + view controls */} {/* 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 flex-wrap gap-2 shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2> <h2 className="font-heading text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View toggle */} {/* View toggle */}
@ -544,7 +542,7 @@ export default function ProjectDetail() {
<div className="flex-1 overflow-hidden flex"> <div className="flex-1 overflow-hidden flex">
{/* Left panel: task list or kanban */} {/* 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={`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 ? ( {topLevelTasks.length === 0 ? (
<EmptyState <EmptyState
icon={ListChecks} icon={ListChecks}

View File

@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
@ -70,10 +71,19 @@ export default function ProjectsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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 min-h-[4rem] flex items-center gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1> <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"> <Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="h-8 text-sm w-auto md:hidden"
>
{statusFilters.map((sf) => (
<option key={sf.value} value={sf.value}>{sf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{statusFilters.map((sf) => ( {statusFilters.map((sf) => (
<button <button
key={sf.value} key={sf.value}
@ -95,7 +105,7 @@ export default function ProjectsPage() {
<div className="flex-1" /> <div className="flex-1" />
<div className="relative"> <div className="relative hidden md:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
@ -106,12 +116,11 @@ export default function ProjectsPage() {
</div> </div>
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">New Project</span>
New Project
</Button> </Button>
</div> </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 */} {/* Summary stats */}
{!isLoading && projects.length > 0 && ( {!isLoading && projects.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -6,6 +6,7 @@ import { isPast, isToday, parseISO } from 'date-fns';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
@ -99,10 +100,19 @@ export default function RemindersPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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 min-h-[4rem] flex items-center gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1> <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"> <Select
value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)}
className="h-8 text-sm w-auto md:hidden"
>
{statusFilters.map((sf) => (
<option key={sf.value} value={sf.value}>{sf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{statusFilters.map((sf) => ( {statusFilters.map((sf) => (
<button <button
key={sf.value} key={sf.value}
@ -125,7 +135,7 @@ export default function RemindersPage() {
<div className="flex-1" /> <div className="flex-1" />
<div className="relative"> <div className="relative hidden md:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
@ -136,8 +146,7 @@ export default function RemindersPage() {
</div> </div>
<Button onClick={handleCreateNew} size="sm"> <Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Reminder</span>
Add Reminder
</Button> </Button>
</div> </div>
@ -148,7 +157,7 @@ export default function RemindersPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' 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 */} {/* Summary stats */}
{!isLoading && reminders.length > 0 && ( {!isLoading && reminders.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -344,7 +344,7 @@ export default function SettingsPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */} {/* 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" /> <Settings className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Settings</h1> <h1 className="text-xl font-semibold font-heading">Settings</h1>
</div> </div>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility'; import type { VisibilityMode } from '@/hooks/useTableVisibility';
import { useMediaQuery } from '@/hooks/useMediaQuery';
export interface ColumnDef<T> { export interface ColumnDef<T> {
key: string; key: string;
@ -28,6 +29,7 @@ interface EntityTableProps<T extends { id: number }> {
onSort: (key: string) => void; onSort: (key: string) => void;
visibilityMode: VisibilityMode; visibilityMode: VisibilityMode;
loading?: boolean; loading?: boolean;
mobileCardRender?: (item: T) => React.ReactNode;
} }
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all']; const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
@ -127,10 +129,51 @@ export function EntityTable<T extends { id: number }>({
onSort, onSort,
visibilityMode, visibilityMode,
loading = false, loading = false,
mobileCardRender,
}: EntityTableProps<T>) { }: EntityTableProps<T>) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length; const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0; const showPinnedSection = showPinned && pinnedRows.length > 0;
const isMobile = useMediaQuery('(max-width: 767px)');
if (isMobile && mobileCardRender) {
return (
<div className="space-y-2">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
))
) : (
<>
{showPinnedSection && (
<>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{pinnedLabel}</p>
{pinnedRows.map((item) => (
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer">
{mobileCardRender(item)}
</div>
))}
</>
)}
{groups.map((group) => (
<React.Fragment key={group.label}>
{group.rows.length > 0 && (
<>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{group.label}</p>
{group.rows.map((item) => (
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer">
{mobileCardRender(item)}
</div>
))}
</>
)}
</React.Fragment>
))}
</>
)}
</div>
);
}
return ( return (
<div className="w-full"> <div className="w-full">

View File

@ -6,6 +6,7 @@ import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils'; import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared'; import { CategoryFilterBar } from '@/components/shared';
@ -128,11 +129,20 @@ export default function TodosPage() {
return ( return (
<div className="flex flex-col h-full animate-fade-in"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* 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 min-h-[4rem] flex items-center gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */} {/* Priority filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <Select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value as typeof priorityFilter)}
className="h-8 text-sm w-auto md:hidden"
>
{priorityFilters.map((pf) => (
<option key={pf.value} value={pf.value}>{pf.label}</option>
))}
</Select>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => ( {priorityFilters.map((pf) => (
<button <button
key={pf.value} key={pf.value}
@ -154,7 +164,7 @@ export default function TodosPage() {
</div> </div>
{/* Category filter bar (All + Completed + Categories with drag) */} {/* Category filter bar (All + Completed + Categories with drag) */}
<div className="flex-1 min-w-0"> <div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
<CategoryFilterBar <CategoryFilterBar
activeFilters={activeFilters} activeFilters={activeFilters}
pinnedLabel="Completed" pinnedLabel="Completed"
@ -171,8 +181,7 @@ export default function TodosPage() {
</div> </div>
<Button onClick={handleCreateNew} size="sm"> <Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Todo</span>
Add Todo
</Button> </Button>
</div> </div>
@ -183,7 +192,7 @@ export default function TodosPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full' 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 */} {/* Summary stats */}
{!isLoading && todos.length > 0 && ( {!isLoading && todos.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5"> <div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -18,7 +18,7 @@ const buttonVariants = cva(
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-11 w-11 md:h-10 md:w-10',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

@ -10,7 +10,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative"> <div className="relative">
<select <select
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

@ -60,7 +60,7 @@ const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={cn( 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' visible ? 'translate-x-0' : 'translate-x-full'
)} )}
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}

View 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;
}

View File

@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
export default { export default {
future: { hoverOnlyWhenSupported: true },
darkMode: ['class'], darkMode: ['class'],
content: [ content: [
'./index.html', './index.html',