UMBRA/frontend/src/components/dashboard/CalendarWidget.tsx
Kyle Pope 6b02cfa1f8 Add ambient dashboard background: drifting orbs, noise texture, vignette, card breathe
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>
2026-03-12 00:54:00 +08:00

125 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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