- S-01/W-06/S-02/S-04: Extract MobileDetailOverlay shared component with Escape key, body scroll lock, and ARIA dialog attributes. Refactored Todos, Reminders, People, Locations, ProjectDetail. - W-02: Add specificity contract comment to mobile-scale CSS - W-03: Enforce 10px floor for text-[9px] on mobile - W-05: Add sort dropdown to EntityTable mobile card view - S-03: Export MOBILE/DESKTOP breakpoint constants from useMediaQuery, updated all 8 consumer files to use constants - S-06: Bump KanbanBoard TouchSensor tolerance from 5 to 8 - S-07: Hover state audit — no action needed, hoverOnlyWhenSupported in Tailwind config already handles touch devices correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
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';
|
|
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
|
import TodoList from './TodoList';
|
|
import TodoDetailPanel from './TodoDetailPanel';
|
|
|
|
const priorityFilters = [
|
|
{ value: '', label: 'All' },
|
|
{ value: 'none', label: 'None' },
|
|
{ value: 'low', label: 'Low' },
|
|
{ value: 'medium', label: 'Medium' },
|
|
{ value: 'high', label: 'High' },
|
|
] as const;
|
|
|
|
export default function TodosPage() {
|
|
const location = useLocation();
|
|
|
|
const isDesktop = useMediaQuery(DESKTOP);
|
|
|
|
// Panel state
|
|
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
|
|
|
// Filters
|
|
const [priorityFilter, setPriorityFilter] = useState('');
|
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
|
const [showCompleted, setShowCompleted] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
|
|
// Handle navigation state from dashboard
|
|
useEffect(() => {
|
|
const state = location.state as { todoId?: number } | null;
|
|
if (state?.todoId) {
|
|
setSelectedTodoId(state.todoId);
|
|
setPanelMode('view');
|
|
window.history.replaceState({}, '');
|
|
}
|
|
}, [location.state]);
|
|
|
|
const { data: todos = [], isLoading } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Todo[]>('/todos');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const allCategories = useMemo(() => {
|
|
const cats = new Set<string>();
|
|
todos.forEach((t) => {
|
|
if (t.category) cats.add(t.category);
|
|
});
|
|
return Array.from(cats).sort();
|
|
}, [todos]);
|
|
|
|
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
|
|
|
|
const filteredTodos = useMemo(
|
|
() =>
|
|
todos.filter((todo) => {
|
|
if (priorityFilter && todo.priority !== priorityFilter) return false;
|
|
if (activeFilters.length > 0) {
|
|
if (!todo.category || !activeFilters.includes(todo.category)) return false;
|
|
}
|
|
if (!showCompleted && todo.completed) return false;
|
|
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
|
|
return false;
|
|
return true;
|
|
}),
|
|
[todos, priorityFilter, activeFilters, showCompleted, search]
|
|
);
|
|
|
|
const totalCount = filteredTodos.filter((t) => !t.completed).length;
|
|
const completedCount = filteredTodos.filter((t) => t.completed).length;
|
|
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
|
|
|
|
const panelOpen = panelMode !== 'closed';
|
|
const selectedTodo = useMemo(
|
|
() => todos.find((t) => t.id === selectedTodoId) ?? null,
|
|
[selectedTodoId, todos],
|
|
);
|
|
|
|
const handleSelect = (todo: Todo) => {
|
|
setSelectedTodoId(todo.id);
|
|
setPanelMode('view');
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
setSelectedTodoId(null);
|
|
setPanelMode('create');
|
|
};
|
|
|
|
const handlePanelClose = () => {
|
|
setPanelMode('closed');
|
|
setSelectedTodoId(null);
|
|
};
|
|
|
|
// CategoryFilterBar handlers
|
|
const toggleAll = () => setActiveFilters([]);
|
|
const toggleCompleted = () => setShowCompleted((p) => !p);
|
|
const toggleCategory = (cat: string) => {
|
|
setActiveFilters((prev) =>
|
|
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
);
|
|
};
|
|
const selectAllCategories = () => {
|
|
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
|
|
setActiveFilters(allSelected ? [] : [...orderedCategories]);
|
|
};
|
|
|
|
// Escape key closes panel
|
|
useEffect(() => {
|
|
if (!panelOpen) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') handlePanelClose();
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [panelOpen]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full animate-fade-in">
|
|
{/* Header */}
|
|
<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">
|
|
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Todos</h1>
|
|
|
|
{/* Priority filter */}
|
|
<Select
|
|
value={priorityFilter}
|
|
onChange={(e) => setPriorityFilter(e.target.value as typeof priorityFilter)}
|
|
className="h-8 text-sm w-auto pr-8 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}
|
|
onClick={() => setPriorityFilter(pf.value)}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
priorityFilter === pf.value
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
style={{
|
|
backgroundColor:
|
|
priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
{pf.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Category filter bar (All + Completed + Categories with drag) */}
|
|
<div className="w-full md:flex-1 md:w-auto min-w-0 order-last md:order-none">
|
|
<CategoryFilterBar
|
|
activeFilters={activeFilters}
|
|
pinnedLabel="Completed"
|
|
showPinned={showCompleted}
|
|
categories={orderedCategories}
|
|
onToggleAll={toggleAll}
|
|
onTogglePinned={toggleCompleted}
|
|
onToggleCategory={toggleCategory}
|
|
onSelectAllCategories={selectAllCategories}
|
|
onReorderCategories={reorderCategories}
|
|
searchValue={search}
|
|
onSearchChange={setSearch}
|
|
/>
|
|
</div>
|
|
|
|
<Button onClick={handleCreateNew} size="sm">
|
|
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Todo</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Main content — list + detail panel */}
|
|
<div className="flex-1 overflow-hidden flex">
|
|
<div
|
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
|
}`}
|
|
>
|
|
<div className="px-4 md:px-6 py-5">
|
|
{/* Summary stats */}
|
|
{!isLoading && todos.length > 0 && (
|
|
<div className="grid gap-1.5 md:gap-2.5 grid-cols-3 mb-5">
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-blue-500/10 hidden sm:block">
|
|
<CheckSquare className="h-4 w-4 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Open
|
|
</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{totalCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-green-500/10 hidden sm:block">
|
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Completed
|
|
</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{completedCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-red-500/10 hidden sm:block">
|
|
<AlertCircle className="h-4 w-4 text-red-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Overdue
|
|
</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{overdueCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<ListSkeleton rows={6} />
|
|
) : (
|
|
<TodoList
|
|
todos={filteredTodos}
|
|
onEdit={handleSelect}
|
|
onAdd={handleCreateNew}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail panel (desktop) */}
|
|
{panelOpen && isDesktop && (
|
|
<div
|
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
|
|
>
|
|
<TodoDetailPanel
|
|
todo={panelMode === 'view' ? selectedTodo : null}
|
|
isCreating={panelMode === 'create'}
|
|
onClose={handlePanelClose}
|
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && !isDesktop && (
|
|
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
|
|
<TodoDetailPanel
|
|
todo={panelMode === 'view' ? selectedTodo : null}
|
|
isCreating={panelMode === 'create'}
|
|
onClose={handlePanelClose}
|
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
|
/>
|
|
</MobileDetailOverlay>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|