Kyle Pope a737f06e85 Action deferred QA items: shared overlay, sort, touch, a11y
- 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>
2026-03-11 03:43:25 +08:00

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