feat(event-card): redesign with motion layout animations, improved date formatting, and contextual metadata

- Wrap card in motion.div with layout/enter/exit animations
- Replace Card/CardHeader/CardContent with flat glass-card div
- Add hover-reveal action menu with opacity transition
- Improve date formatting with locale options for month/day/time
- Show end time inline next to start
- Add ExternalLink for event URLs
- Add icons to dropdown menu items with DropdownMenuSeparator
This commit is contained in:
2026-04-08 00:56:28 -04:00
parent 2992cfbccd
commit c80322f20a

View File

@@ -1,11 +1,21 @@
import { Clock, LucideMapPin, MoreHorizontal } from "lucide-react"; "use client";
import { motion } from "framer-motion";
import {
Clock,
ExternalLink,
LucideMapPin,
MoreHorizontal,
Pencil,
Trash2,
} from "lucide-react";
import { RRuleDisplay } from "@/components/rrule-display"; import { RRuleDisplay } from "@/components/rrule-display";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
@@ -19,8 +29,17 @@ interface EventCardProps {
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => { const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
return allDay return allDay
? new Date(dateStr).toLocaleDateString() ? new Date(dateStr).toLocaleDateString(undefined, {
: new Date(dateStr).toLocaleString(); month: "short",
day: "numeric",
year: "numeric",
})
: new Date(dateStr).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}; };
const handleEdit = () => { const handleEdit = () => {
@@ -36,60 +55,92 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
}); });
}; };
const endDate =
event.end && !event.allDay ? formatDateTime(event.end, event.allDay) : null;
return ( return (
<Card className="w-full"> <motion.div
<CardHeader className="pb-3"> layout
<div className="flex items-start justify-between"> initial={{ opacity: 0, y: 8 }}
<div className="space-y-1 flex-1"> animate={{ opacity: 1, y: 0 }}
<h3 className="font-semibold leading-none tracking-tight"> exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
transition={{ duration: 0.2 }}
>
<div className="glass-card p-4 group cursor-pointer hover:bg-accent/50 transition-colors duration-150">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0 space-y-1.5">
<h3 className="font-medium text-sm leading-snug truncate">
{event.title} {event.title}
</h3> </h3>
{event.recurrenceRule && (
<div className="mt-1">
<RRuleDisplay rrule={event.recurrenceRule} />
</div>
)}
{event.description && ( {event.description && (
<p className="text-sm text-muted-foreground mt-2 break-words"> <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{event.description} {event.description}
</p> </p>
)} )}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3 shrink-0" />
{formatDateTime(event.start, event.allDay)}
{endDate && <span className="text-muted-foreground/50">-</span>}
{endDate}
</span>
{event.location && (
<span className="inline-flex items-center gap-1 truncate">
<LucideMapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{event.location}</span>
</span>
)}
{event.url && (
<a
href={event.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary/70 hover:text-primary transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-3 w-3" />
<span className="truncate max-w-[120px]">Link</span>
</a>
)}
</div> </div>
{event.recurrenceRule && (
<RRuleDisplay rrule={event.recurrenceRule} />
)}
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button
<MoreHorizontal className="h-4 w-4" /> variant="ghost"
<span className="sr-only">Open menu</span> size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="sr-only">Event actions</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={handleEdit}>Edit</DropdownMenuItem> <DropdownMenuItem onClick={handleEdit}>
<Pencil className="h-3.5 w-3.5 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(event.id)} onClick={() => onDelete(event.id)}
className="text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2 className="h-3.5 w-3.5 mr-2" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{formatDateTime(event.start, event.allDay)}
</div> </div>
</motion.div>
{event.location && (
<div className="flex items-center text-sm text-muted-foreground">
<LucideMapPin className="mr-2 h-4 w-4" />
{event.location}
</div>
)}
</div>
</CardContent>
</Card>
); );
}; };