Compare commits
No commits in common. "4e9194495662d4dec611c64ccf1694e17a9e1305" and "e51b09f9c537ff4203409629bbc84f65198a303d" have entirely different histories.
4e91944956
...
e51b09f9c5
@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
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';
|
||||||
@ -20,7 +20,6 @@ import { Select } from '@/components/ui/select';
|
|||||||
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
||||||
import CalendarSidebar from './CalendarSidebar';
|
import CalendarSidebar from './CalendarSidebar';
|
||||||
import EventDetailPanel from './EventDetailPanel';
|
import EventDetailPanel from './EventDetailPanel';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
import type { CreateDefaults } from './EventDetailPanel';
|
import type { CreateDefaults } from './EventDetailPanel';
|
||||||
|
|
||||||
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||||
@ -165,7 +164,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 = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Continuously resize calendar during panel open/close CSS transition
|
// Continuously resize calendar during panel open/close CSS transition
|
||||||
@ -640,18 +639,26 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && !isDesktop && (
|
{panelOpen && !isDesktop && (
|
||||||
<MobileDetailOverlay open onClose={handlePanelClose} className="sm:max-w-[400px]">
|
<div
|
||||||
<EventDetailPanel
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
event={panelMode === 'view' ? selectedEvent : null}
|
onClick={handlePanelClose}
|
||||||
isCreating={panelMode === 'create'}
|
>
|
||||||
createDefaults={createDefaults}
|
<div
|
||||||
onClose={handlePanelClose}
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg overflow-y-auto"
|
||||||
onSaved={handlePanelClose}
|
onClick={(e) => e.stopPropagation()}
|
||||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
>
|
||||||
myPermission={selectedEventPermission}
|
<EventDetailPanel
|
||||||
isSharedEvent={selectedEventIsShared}
|
event={panelMode === 'view' ? selectedEvent : null}
|
||||||
/>
|
isCreating={panelMode === 'create'}
|
||||||
</MobileDetailOverlay>
|
createDefaults={createDefaults}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={handlePanelClose}
|
||||||
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||||
|
myPermission={selectedEventPermission}
|
||||||
|
isSharedEvent={selectedEventIsShared}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
|
import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -17,11 +17,10 @@ import {
|
|||||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||||
import LocationForm from './LocationForm';
|
import LocationForm from './LocationForm';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
|
|
||||||
export default function LocationsPage() {
|
export default function LocationsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDesktop = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -388,9 +387,17 @@ export default function LocationsPage() {
|
|||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && selectedLocation && !isDesktop && (
|
{panelOpen && selectedLocation && !isDesktop && (
|
||||||
<MobileDetailOverlay open={true} onClose={() => setSelectedLocationId(null)}>
|
<div
|
||||||
{renderPanel()}
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
</MobileDetailOverlay>
|
onClick={() => setSelectedLocationId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@ -27,7 +27,6 @@ import PersonForm from './PersonForm';
|
|||||||
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
||||||
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
|
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// StatCounter — inline helper
|
// StatCounter — inline helper
|
||||||
@ -216,7 +215,7 @@ const panelFields: PanelField[] = [
|
|||||||
export default function PeoplePage() {
|
export default function PeoplePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const isDesktop = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
|
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -777,9 +776,17 @@ export default function PeoplePage() {
|
|||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && selectedPerson && !isDesktop && (
|
{panelOpen && selectedPerson && !isDesktop && (
|
||||||
<MobileDetailOverlay open={true} onClose={() => setSelectedPersonId(null)}>
|
<div
|
||||||
{renderPanel()}
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
</MobileDetailOverlay>
|
onClick={() => setSelectedPersonId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderPanel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export default function KanbanBoard({
|
|||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
|
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } 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';
|
||||||
@ -39,7 +39,6 @@ import KanbanBoard from './KanbanBoard';
|
|||||||
import TaskForm from './TaskForm';
|
import TaskForm from './TaskForm';
|
||||||
import ProjectForm from './ProjectForm';
|
import ProjectForm from './ProjectForm';
|
||||||
import { statusColors, statusLabels } from './constants';
|
import { statusColors, statusLabels } from './constants';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
|
|
||||||
type SortMode = 'manual' | 'priority' | 'due_date';
|
type SortMode = 'manual' | 'priority' | 'due_date';
|
||||||
type ViewMode = 'list' | 'kanban';
|
type ViewMode = 'list' | 'kanban';
|
||||||
@ -259,7 +258,7 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}, [topLevelTasks, sortMode, sortSubtasks]);
|
}, [topLevelTasks, sortMode, sortSubtasks]);
|
||||||
|
|
||||||
const isDesktop = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
const selectedTask = useMemo(() => {
|
const selectedTask = useMemo(() => {
|
||||||
if (!selectedTaskId) return null;
|
if (!selectedTaskId) return null;
|
||||||
@ -654,28 +653,30 @@ export default function ProjectDetail() {
|
|||||||
|
|
||||||
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
||||||
{selectedTaskId && selectedTask && !isDesktop && (
|
{selectedTaskId && selectedTask && !isDesktop && (
|
||||||
<MobileDetailOverlay open={true} onClose={() => setSelectedTaskId(null)}>
|
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<Button
|
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setSelectedTaskId(null)}
|
size="sm"
|
||||||
>
|
onClick={() => setSelectedTaskId(null)}
|
||||||
Close
|
>
|
||||||
</Button>
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-49px)]">
|
||||||
|
<TaskDetailPanel
|
||||||
|
task={selectedTask}
|
||||||
|
projectId={parseInt(id!)}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||||
|
onClose={() => setSelectedTaskId(null)}
|
||||||
|
onSelectTask={setSelectedTaskId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-49px)]">
|
</div>
|
||||||
<TaskDetailPanel
|
|
||||||
task={selectedTask}
|
|
||||||
projectId={parseInt(id!)}
|
|
||||||
onDelete={handleDeleteTask}
|
|
||||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
|
||||||
onClose={() => setSelectedTaskId(null)}
|
|
||||||
onSelectTask={setSelectedTaskId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</MobileDetailOverlay>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTaskForm && (
|
{showTaskForm && (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@ -13,7 +13,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
import ReminderList from './ReminderList';
|
import ReminderList from './ReminderList';
|
||||||
import ReminderDetailPanel from './ReminderDetailPanel';
|
import ReminderDetailPanel from './ReminderDetailPanel';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'active', label: 'Active' },
|
||||||
@ -26,7 +25,7 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
|
|||||||
export default function RemindersPage() {
|
export default function RemindersPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isDesktop = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
// Panel state
|
// Panel state
|
||||||
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
||||||
@ -236,14 +235,22 @@ export default function RemindersPage() {
|
|||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && !isDesktop && (
|
{panelOpen && !isDesktop && (
|
||||||
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
|
<div
|
||||||
<ReminderDetailPanel
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
onClick={handlePanelClose}
|
||||||
isCreating={panelMode === 'create'}
|
>
|
||||||
onClose={handlePanelClose}
|
<div
|
||||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</MobileDetailOverlay>
|
>
|
||||||
|
<ReminderDetailPanel
|
||||||
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronsUpDown } from 'lucide-react';
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
||||||
import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
|
||||||
export interface ColumnDef<T> {
|
export interface ColumnDef<T> {
|
||||||
key: string;
|
key: string;
|
||||||
@ -134,30 +134,11 @@ export function EntityTable<T extends { id: number }>({
|
|||||||
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(MOBILE);
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||||
|
|
||||||
const sortableColumns = columns.filter((col) => col.sortable);
|
|
||||||
|
|
||||||
if (isMobile && mobileCardRender) {
|
if (isMobile && mobileCardRender) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sortableColumns.length > 0 && !loading && (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<select
|
|
||||||
value={sortKey}
|
|
||||||
onChange={(e) => onSort(e.target.value)}
|
|
||||||
aria-label="Sort by"
|
|
||||||
className="h-7 rounded-md border border-border bg-card px-2 text-xs text-foreground"
|
|
||||||
>
|
|
||||||
{sortableColumns.map((col) => (
|
|
||||||
<option key={col.key} value={col.key}>
|
|
||||||
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
|
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface MobileDetailOverlayProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen overlay for mobile detail panels.
|
|
||||||
* - Backdrop click closes the overlay
|
|
||||||
* - Escape key closes the overlay
|
|
||||||
* - Body scroll is locked while open
|
|
||||||
*/
|
|
||||||
export default function MobileDetailOverlay({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: MobileDetailOverlayProps) {
|
|
||||||
// Stable ref to avoid re-registering listener on every render
|
|
||||||
const onCloseRef = useRef(onClose);
|
|
||||||
onCloseRef.current = onClose;
|
|
||||||
|
|
||||||
// Escape key handler
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onCloseRef.current();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handler);
|
|
||||||
return () => document.removeEventListener('keydown', handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Body scroll lock
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const previous = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previous;
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-fade-in"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute right-0 top-0 h-full w-full sm:max-w-md bg-card border-l border-border shadow-xl overflow-y-auto',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@ -11,7 +11,6 @@ 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';
|
||||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
||||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import TodoDetailPanel from './TodoDetailPanel';
|
import TodoDetailPanel from './TodoDetailPanel';
|
||||||
@ -27,7 +26,7 @@ const priorityFilters = [
|
|||||||
export default function TodosPage() {
|
export default function TodosPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isDesktop = useMediaQuery(DESKTOP);
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
|
||||||
// Panel state
|
// Panel state
|
||||||
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
||||||
@ -271,14 +270,22 @@ export default function TodosPage() {
|
|||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && !isDesktop && (
|
{panelOpen && !isDesktop && (
|
||||||
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
|
<div
|
||||||
<TodoDetailPanel
|
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||||
todo={panelMode === 'view' ? selectedTodo : null}
|
onClick={handlePanelClose}
|
||||||
isCreating={panelMode === 'create'}
|
>
|
||||||
onClose={handlePanelClose}
|
<div
|
||||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</MobileDetailOverlay>
|
>
|
||||||
|
<TodoDetailPanel
|
||||||
|
todo={panelMode === 'view' ? selectedTodo : null}
|
||||||
|
isCreating={panelMode === 'create'}
|
||||||
|
onClose={handlePanelClose}
|
||||||
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
|
||||||
// ── Browser detection (stable — checked once at module load) ──
|
// ── Browser detection (stable — checked once at module load) ──
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
|||||||
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||||
const isMobile = useMediaQuery(MOBILE);
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => triggerRef.current!);
|
React.useImperativeHandle(ref, () => triggerRef.current!);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export const MOBILE = '(max-width: 767px)';
|
|
||||||
export const DESKTOP = '(min-width: 1024px)';
|
|
||||||
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const [matches, setMatches] = useState(() =>
|
const [matches, setMatches] = useState(() =>
|
||||||
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
|
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
|
||||||
|
|||||||
@ -292,12 +292,6 @@ form[data-submitted] input:invalid + button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Mobile font scaling — overrides Tailwind text utilities below 768px.
|
|
||||||
* Applied via .mobile-scale class on <main> in AppLayout.tsx.
|
|
||||||
* These selectors rely on cascade order (loaded after Tailwind utilities).
|
|
||||||
* Arbitrary sizes like text-[9px] are NOT captured — see W-03 override below.
|
|
||||||
*/
|
|
||||||
/* ── Global mobile content scaling ── */
|
/* ── Global mobile content scaling ── */
|
||||||
/* Scales down all page content text on mobile. Navbar and UMBRA title are excluded
|
/* Scales down all page content text on mobile. Navbar and UMBRA title are excluded
|
||||||
because they live outside .mobile-scale and use their own sizing. */
|
because they live outside .mobile-scale and use their own sizing. */
|
||||||
@ -329,10 +323,6 @@ form[data-submitted] input:invalid + button {
|
|||||||
.mobile-scale .text-3xl {
|
.mobile-scale .text-3xl {
|
||||||
font-size: 1.375rem; /* 22px */
|
font-size: 1.375rem; /* 22px */
|
||||||
}
|
}
|
||||||
/* W-03: Enforce 10px floor for arbitrary small sizes */
|
|
||||||
.mobile-scale [class*="text-\[9px\]"] {
|
|
||||||
font-size: 10px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile touch optimisation ── */
|
/* ── Mobile touch optimisation ── */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user