- Wrap CategoryFilterBar in flex-1 min-w-0 so search aligns right - Add first_name, last_name, nickname to People search filter - Add ring-inset to all header search inputs (People, Todos, Locations, Reminders, Calendar) to prevent focus ring clipping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
8.9 KiB
TypeScript
246 lines
8.9 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { isPast, isToday, parseISO } from 'date-fns';
|
|
import api from '@/lib/api';
|
|
import type { Reminder } from '@/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
|
import ReminderList from './ReminderList';
|
|
import ReminderDetailPanel from './ReminderDetailPanel';
|
|
|
|
const statusFilters = [
|
|
{ value: 'active', label: 'Active' },
|
|
{ value: 'dismissed', label: 'Dismissed' },
|
|
{ value: 'all', label: 'All' },
|
|
] as const;
|
|
|
|
type StatusFilter = (typeof statusFilters)[number]['value'];
|
|
|
|
export default function RemindersPage() {
|
|
const location = useLocation();
|
|
|
|
// Panel state
|
|
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
|
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
|
|
|
|
const [filter, setFilter] = useState<StatusFilter>('active');
|
|
const [search, setSearch] = useState('');
|
|
|
|
// Handle navigation state from dashboard
|
|
useEffect(() => {
|
|
const state = location.state as { reminderId?: number } | null;
|
|
if (state?.reminderId) {
|
|
setSelectedReminderId(state.reminderId);
|
|
setPanelMode('view');
|
|
window.history.replaceState({}, '');
|
|
}
|
|
}, [location.state]);
|
|
|
|
const { data: reminders = [], isLoading } = useQuery({
|
|
queryKey: ['reminders'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Reminder[]>('/reminders');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const filteredReminders = useMemo(
|
|
() =>
|
|
reminders.filter((r) => {
|
|
if (filter === 'active' && r.is_dismissed) return false;
|
|
if (filter === 'dismissed' && !r.is_dismissed) return false;
|
|
if (search && !r.title.toLowerCase().includes(search.toLowerCase())) return false;
|
|
return true;
|
|
}),
|
|
[reminders, filter, search]
|
|
);
|
|
|
|
const activeCount = reminders.filter((r) => !r.is_dismissed).length;
|
|
const overdueCount = reminders.filter(
|
|
(r) => !r.is_dismissed && r.remind_at && isPast(parseISO(r.remind_at)) && !isToday(parseISO(r.remind_at))
|
|
).length;
|
|
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
|
|
|
|
const panelOpen = panelMode !== 'closed';
|
|
const selectedReminder = useMemo(
|
|
() => reminders.find((r) => r.id === selectedReminderId) ?? null,
|
|
[selectedReminderId, reminders],
|
|
);
|
|
|
|
const handleSelect = (reminder: Reminder) => {
|
|
setSelectedReminderId(reminder.id);
|
|
setPanelMode('view');
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
setSelectedReminderId(null);
|
|
setPanelMode('create');
|
|
};
|
|
|
|
const handlePanelClose = () => {
|
|
setPanelMode('closed');
|
|
setSelectedReminderId(null);
|
|
};
|
|
|
|
// 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-6 h-16 flex items-center gap-4 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">
|
|
{statusFilters.map((sf) => (
|
|
<button
|
|
key={sf.value}
|
|
onClick={() => setFilter(sf.value)}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
filter === sf.value
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
style={{
|
|
backgroundColor:
|
|
filter === sf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: filter === sf.value ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
{sf.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="relative">
|
|
<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..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-52 h-8 pl-8 text-sm ring-inset"
|
|
/>
|
|
</div>
|
|
|
|
<Button onClick={handleCreateNew} size="sm">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Reminder
|
|
</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-6 py-5">
|
|
{/* Summary stats */}
|
|
{!isLoading && reminders.length > 0 && (
|
|
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-1.5 rounded-md bg-orange-500/10">
|
|
<Bell className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Active
|
|
</p>
|
|
<p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-1.5 rounded-md bg-red-500/10">
|
|
<AlertCircle className="h-4 w-4 text-red-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Overdue
|
|
</p>
|
|
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="p-1.5 rounded-md bg-gray-500/10">
|
|
<BellOff className="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
|
Dismissed
|
|
</p>
|
|
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<ListSkeleton rows={6} />
|
|
) : (
|
|
<ReminderList
|
|
reminders={filteredReminders}
|
|
onEdit={handleSelect}
|
|
onAdd={handleCreateNew}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail panel (desktop) */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
}`}
|
|
>
|
|
<ReminderDetailPanel
|
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
|
isCreating={panelMode === 'create'}
|
|
onClose={handlePanelClose}
|
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && (
|
|
<div
|
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
|
onClick={handlePanelClose}
|
|
>
|
|
<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()}
|
|
>
|
|
<ReminderDetailPanel
|
|
reminder={panelMode === 'view' ? selectedReminder : null}
|
|
isCreating={panelMode === 'create'}
|
|
onClose={handlePanelClose}
|
|
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|