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>
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { format } from 'date-fns';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Calendar } from 'lucide-react';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface DashboardEvent {
|
||
id: number;
|
||
title: string;
|
||
start_datetime: string;
|
||
end_datetime: string;
|
||
all_day: boolean;
|
||
color?: string;
|
||
is_starred?: boolean;
|
||
}
|
||
|
||
interface CalendarWidgetProps {
|
||
events: DashboardEvent[];
|
||
}
|
||
|
||
function getEventTimeState(event: DashboardEvent, now: Date) {
|
||
if (event.all_day) return 'all-day' as const;
|
||
const start = new Date(event.start_datetime).getTime();
|
||
const end = new Date(event.end_datetime).getTime();
|
||
const current = now.getTime();
|
||
if (current >= end) return 'past' as const;
|
||
if (current >= start && current < end) return 'current' as const;
|
||
return 'future' as const;
|
||
}
|
||
|
||
function getProgressPercent(event: DashboardEvent, now: Date): number {
|
||
if (event.all_day) return 0;
|
||
const start = new Date(event.start_datetime).getTime();
|
||
const end = new Date(event.end_datetime).getTime();
|
||
if (end <= start) return 0;
|
||
const current = now.getTime();
|
||
if (current >= end) return 100;
|
||
if (current <= start) return 0;
|
||
return Math.round(((current - start) / (end - start)) * 100);
|
||
}
|
||
|
||
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||
const navigate = useNavigate();
|
||
const todayStr = format(new Date(), 'yyyy-MM-dd');
|
||
const [clientNow, setClientNow] = useState(() => new Date());
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => setClientNow(new Date()), 60_000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const hasCurrentEvent = events.some((e) => getEventTimeState(e, clientNow) === 'current');
|
||
|
||
return (
|
||
<Card className={cn('hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasCurrentEvent && 'animate-card-breathe')}>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||
<Calendar className="h-4 w-4 text-purple-400" />
|
||
</div>
|
||
Today's Events
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{events.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
||
<div className="rounded-full bg-muted p-4">
|
||
<Calendar className="h-8 w-8 text-muted-foreground" />
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Enjoy the free time</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-0.5">
|
||
{events.map((event) => {
|
||
const timeState = getEventTimeState(event, clientNow);
|
||
const progress = getProgressPercent(event, clientNow);
|
||
const isCurrent = timeState === 'current';
|
||
const isPast = timeState === 'past';
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
|
||
className={cn(
|
||
'flex items-center gap-2 py-1.5 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer relative pl-3.5',
|
||
isCurrent && 'bg-accent/[0.05]',
|
||
isPast && 'opacity-50'
|
||
)}
|
||
>
|
||
{/* Time progress bar — always rendered for consistent layout */}
|
||
<div
|
||
className="absolute left-0 top-1 bottom-1 w-0.5 rounded-full overflow-hidden"
|
||
style={{ backgroundColor: event.all_day ? 'transparent' : 'hsl(var(--border))' }}
|
||
>
|
||
{!event.all_day && (
|
||
<div
|
||
className={cn('w-full rounded-full transition-all duration-1000', isPast && 'opacity-50')}
|
||
style={{
|
||
height: `${progress}%`,
|
||
backgroundColor: event.color || 'hsl(var(--accent-color))',
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
|
||
/>
|
||
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
|
||
{event.all_day
|
||
? 'All day'
|
||
: `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`}
|
||
</span>
|
||
<span className="text-sm font-medium truncate">{event.title}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|