Compare commits
3 Commits
36309c2460
...
09c35752c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c35752c6 | |||
| d0477b1c13 | |||
| 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';
|
||||
@ -15,6 +16,7 @@ import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import CalendarSidebar from './CalendarSidebar';
|
||||
import EventDetailPanel from './EventDetailPanel';
|
||||
import type { CreateDefaults } from './EventDetailPanel';
|
||||
@ -161,13 +163,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 +479,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 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">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@ -496,7 +492,17 @@ export default function CalendarPage() {
|
||||
Today
|
||||
</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]) => (
|
||||
<button
|
||||
key={view}
|
||||
@ -516,12 +522,12 @@ export default function CalendarPage() {
|
||||
))}
|
||||
</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" />
|
||||
|
||||
{/* 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" />
|
||||
<Input
|
||||
placeholder="Search events..."
|
||||
@ -558,8 +564,7 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={handleCreateNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Event
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Create Event</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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,10 +285,10 @@ 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 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>
|
||||
|
||||
<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
|
||||
categories={orderedCategories}
|
||||
activeFilters={activeFilters}
|
||||
@ -305,8 +305,7 @@ export default function LocationsPage() {
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add location">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Location
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Location</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -320,7 +319,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}
|
||||
@ -357,6 +356,17 @@ export default function LocationsPage() {
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
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>
|
||||
|
||||
@ -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,9 +555,9 @@ 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 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>
|
||||
<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
|
||||
activeFilters={activeFilters}
|
||||
pinnedLabel="Favourites"
|
||||
@ -587,8 +587,7 @@ export default function PeoplePage() {
|
||||
aria-label="Add person"
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Person
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Person</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -622,7 +621,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 +664,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 +705,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}
|
||||
@ -745,6 +744,18 @@ export default function PeoplePage() {
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
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>
|
||||
|
||||
@ -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 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')}>
|
||||
<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>
|
||||
@ -396,8 +396,7 @@ export default function ProjectDetail() {
|
||||
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -409,15 +408,14 @@ export default function ProjectDetail() {
|
||||
}}
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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 +488,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 flex-wrap gap-2 shrink-0">
|
||||
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
@ -544,7 +542,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}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { GridSkeleton } from '@/components/ui/skeleton';
|
||||
@ -70,10 +71,19 @@ 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 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>
|
||||
|
||||
<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) => (
|
||||
<button
|
||||
key={sf.value}
|
||||
@ -95,7 +105,7 @@ export default function ProjectsPage() {
|
||||
|
||||
<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" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
@ -106,12 +116,11 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Project
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">New Project</span>
|
||||
</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">
|
||||
|
||||
@ -6,6 +6,7 @@ import { isPast, isToday, parseISO } from 'date-fns';
|
||||
import api from '@/lib/api';
|
||||
import type { Reminder } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
@ -99,10 +100,19 @@ 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 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>
|
||||
|
||||
<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) => (
|
||||
<button
|
||||
key={sf.value}
|
||||
@ -125,7 +135,7 @@ export default function RemindersPage() {
|
||||
|
||||
<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" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
@ -136,8 +146,7 @@ export default function RemindersPage() {
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Reminder
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Reminder</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -148,7 +157,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>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
|
||||
export interface ColumnDef<T> {
|
||||
key: string;
|
||||
@ -28,6 +29,7 @@ interface EntityTableProps<T extends { id: number }> {
|
||||
onSort: (key: string) => void;
|
||||
visibilityMode: VisibilityMode;
|
||||
loading?: boolean;
|
||||
mobileCardRender?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
|
||||
@ -127,10 +129,51 @@ export function EntityTable<T extends { id: number }>({
|
||||
onSort,
|
||||
visibilityMode,
|
||||
loading = false,
|
||||
mobileCardRender,
|
||||
}: EntityTableProps<T>) {
|
||||
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
|
||||
const colCount = visibleColumns.length;
|
||||
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 (
|
||||
<div className="w-full">
|
||||
|
||||
@ -6,6 +6,7 @@ import api from '@/lib/api';
|
||||
import type { Todo } from '@/types';
|
||||
import { isTodoOverdue } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
import { CategoryFilterBar } from '@/components/shared';
|
||||
@ -128,11 +129,20 @@ 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 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>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={pf.value}
|
||||
@ -154,7 +164,7 @@ export default function TodosPage() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
activeFilters={activeFilters}
|
||||
pinnedLabel="Completed"
|
||||
@ -171,8 +181,7 @@ export default function TodosPage() {
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Todo
|
||||
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Todo</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -183,7 +192,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