Three layered effects to make the dashboard feel alive: 1. DashboardAmbient: two accent-colored drifting orbs at very low opacity (0.04/0.025) with 120px blur — subtle depth and movement 2. Noise texture + radial vignette via CSS pseudo-elements — breaks the flat digital surface and draws focus to center content 3. Card breathe animation: data-driven 4s pulsing glow on CalendarWidget (when event in progress) and TodoWidget (when overdue todos exist) All effects respect prefers-reduced-motion, use accent CSS vars (works with any user-chosen accent color), and are GPU-composited (transform + opacity only) for negligible performance cost. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { format, isPast, endOfDay } from 'date-fns';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { CheckCircle2, Check } from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { cn } from '@/lib/utils';
|
|
import api from '@/lib/api';
|
|
|
|
interface DashboardTodo {
|
|
id: number;
|
|
title: string;
|
|
due_date: string;
|
|
priority: string;
|
|
category?: string;
|
|
}
|
|
|
|
interface TodoWidgetProps {
|
|
todos: DashboardTodo[];
|
|
}
|
|
|
|
const priorityColors: Record<string, string> = {
|
|
low: 'bg-green-500/10 text-green-400 border-green-500/20',
|
|
medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
|
high: 'bg-red-500/10 text-red-400 border-red-500/20',
|
|
};
|
|
|
|
const dotColors: Record<string, string> = {
|
|
high: 'bg-red-400',
|
|
medium: 'bg-yellow-400',
|
|
low: 'bg-green-400',
|
|
};
|
|
|
|
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
|
|
|
const toggleTodo = useMutation({
|
|
mutationFn: (id: number) => api.patch(`/todos/${id}/toggle`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
|
toast.success('Todo completed');
|
|
},
|
|
onError: () => toast.error('Failed to complete todo'),
|
|
});
|
|
|
|
const hasOverdue = todos.some((t) => isPast(endOfDay(new Date(t.due_date))));
|
|
|
|
return (
|
|
<Card className={cn('h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasOverdue && 'animate-card-breathe')}>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-blue-500/10">
|
|
<CheckCircle2 className="h-4 w-4 text-blue-400" />
|
|
</div>
|
|
Upcoming Todos
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{todos.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
|
<div className="rounded-full bg-muted p-4">
|
|
<CheckCircle2 className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">Your slate is clean</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{todos.slice(0, 5).map((todo) => {
|
|
const isOverdue = isPast(endOfDay(new Date(todo.due_date)));
|
|
const isHovered = hoveredId === todo.id;
|
|
return (
|
|
<div
|
|
key={todo.id}
|
|
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
|
|
onMouseEnter={() => setHoveredId(todo.id)}
|
|
onMouseLeave={() => setHoveredId(null)}
|
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer"
|
|
>
|
|
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
|
|
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
|
|
<div className="relative flex items-center gap-1.5 shrink-0">
|
|
<div className={cn('flex items-center gap-1.5', isHovered && 'invisible')}>
|
|
<span className={cn(
|
|
'text-xs shrink-0 whitespace-nowrap',
|
|
isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
|
)}>
|
|
{format(new Date(todo.due_date), 'MMM d')}
|
|
{isOverdue && ' overdue'}
|
|
</span>
|
|
<Badge className={cn('text-[9px] shrink-0 py-0', priorityColors[todo.priority] || priorityColors.medium)}>
|
|
{todo.priority}
|
|
</Badge>
|
|
</div>
|
|
{isHovered && (
|
|
<div className="absolute inset-0 flex items-center justify-end">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleTodo.mutate(todo.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-green-500/15 text-muted-foreground hover:text-green-400 transition-colors"
|
|
title="Complete todo"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|