UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,795 lines (1,557 loc) 77.2 kB
--- title: Mail dimension: things category: plans tags: ai, architecture, artificial-intelligence related_dimensions: knowledge, people scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the things dimension in the plans category. Location: one/things/plans/mail.md Purpose: Documents comprehensive prompt for claude code: recreating shadcn mail layout in astro Related dimensions: knowledge, people For AI agents: Read this to understand mail. --- # COMPREHENSIVE PROMPT FOR CLAUDE CODE: Recreating shadcn Mail Layout in Astro ## Project Overview Create a complete mail application layout using Astro with React 19, Tailwind CSS 4, and shadcn/ui. This implementation replicates the official shadcn mail example with adaptations for Astro's islands architecture, ensuring proper responsive behavior (sidebar pushes content on desktop/tablet, overlays on mobile) and full client-side interactivity. --- ## INTERACTIVE FEATURES IMPLEMENTATION This section provides a comprehensive, step-by-step guide to making the mail app fully interactive with real-time filtering, dynamic state management, responsive navigation, and user feedback mechanisms. ### Architecture Overview The interactive features rely on a multi-layered state management approach: 1. **Global State (Jotai)**: Selected email, active folder, search query, unread counts 2. **Local Component State (React useState)**: UI-specific states like dropdown open/closed, form values 3. **Persistent State (localStorage/cookies)**: Sidebar collapsed state, panel sizes, user preferences 4. **URL State (Astro routing)**: Current folder, search parameters, selected email ID ### 1. NAVIGATION FEATURES #### 1.1 Clickable Folder Navigation with Active States Create an enhanced state management hook that tracks the active folder: ```typescript // src/components/mail/use-mail.ts import { atom, useAtom } from "jotai"; import { Mail, mails } from "@/data/mail-data"; export type MailFolder = | "inbox" | "drafts" | "sent" | "junk" | "trash" | "archive" | "social" | "updates" | "forums" | "shopping" | "promotions"; type Config = { selected: Mail["id"] | null; activeFolder: MailFolder; searchQuery: string; }; const configAtom = atom<Config>({ selected: mails[0].id, activeFolder: "inbox", searchQuery: "", }); export function useMail() { return useAtom(configAtom); } // Computed atom for filtered mails based on folder and search const filteredMailsAtom = atom((get) => { const config = get(configAtom); let filtered = mails; // Filter by folder switch (config.activeFolder) { case "inbox": filtered = mails.filter( (m) => !m.labels.includes("draft") && !m.labels.includes("sent"), ); break; case "drafts": filtered = mails.filter((m) => m.labels.includes("draft")); break; case "sent": filtered = mails.filter((m) => m.labels.includes("sent")); break; case "junk": filtered = mails.filter((m) => m.labels.includes("junk")); break; case "trash": filtered = mails.filter((m) => m.labels.includes("trash")); break; case "archive": filtered = mails.filter((m) => m.labels.includes("archive")); break; case "social": filtered = mails.filter((m) => m.labels.includes("social")); break; case "updates": filtered = mails.filter((m) => m.labels.includes("updates")); break; case "forums": filtered = mails.filter((m) => m.labels.includes("forums")); break; case "shopping": filtered = mails.filter((m) => m.labels.includes("shopping")); break; case "promotions": filtered = mails.filter((m) => m.labels.includes("promotions")); break; } // Filter by search query if (config.searchQuery) { const query = config.searchQuery.toLowerCase(); filtered = filtered.filter( (m) => m.name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query) || m.subject.toLowerCase().includes(query) || m.text.toLowerCase().includes(query), ); } return filtered; }); export function useFilteredMails() { return useAtom(filteredMailsAtom); } // Helper to calculate badge counts export function useBadgeCounts() { const counts = { inbox: mails.filter( (m) => !m.labels.includes("draft") && !m.labels.includes("sent"), ).length, drafts: mails.filter((m) => m.labels.includes("draft")).length, sent: mails.filter((m) => m.labels.includes("sent")).length, junk: mails.filter((m) => m.labels.includes("junk")).length, trash: mails.filter((m) => m.labels.includes("trash")).length, archive: mails.filter((m) => m.labels.includes("archive")).length, social: mails.filter((m) => m.labels.includes("social")).length, updates: mails.filter((m) => m.labels.includes("updates")).length, forums: mails.filter((m) => m.labels.includes("forums")).length, shopping: mails.filter((m) => m.labels.includes("shopping")).length, promotions: mails.filter((m) => m.labels.includes("promotions")).length, }; return counts; } ``` #### 1.2 Enhanced Nav Component with Click Handlers Update the Nav component to support click handlers and active states: ```tsx // src/components/mail/Nav.tsx import { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useMail, MailFolder } from "./use-mail"; interface NavProps { isCollapsed: boolean; links: { title: string; label?: string; icon: LucideIcon; variant: "default" | "ghost"; folder: MailFolder; }[]; } export function Nav({ links, isCollapsed }: NavProps) { const [mail, setMail] = useMail(); const handleFolderClick = (folder: MailFolder) => { setMail({ ...mail, activeFolder: folder, selected: null, // Clear selection when switching folders }); }; return ( <div data-collapsed={isCollapsed} className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2" > <nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"> {links.map((link, index) => { const isActive = mail.activeFolder === link.folder; return isCollapsed ? ( <Tooltip key={index} delayDuration={0}> <TooltipTrigger asChild> <button onClick={() => handleFolderClick(link.folder)} className={cn( buttonVariants({ variant: isActive ? "default" : "ghost", size: "icon", }), "h-9 w-9 transition-all duration-200", isActive && "dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white", )} > <link.icon className="size-4" /> <span className="sr-only">{link.title}</span> </button> </TooltipTrigger> <TooltipContent side="right" className="flex items-center gap-4"> {link.title} {link.label && ( <span className="ml-auto text-muted-foreground"> {link.label} </span> )} </TooltipContent> </Tooltip> ) : ( <button key={index} onClick={() => handleFolderClick(link.folder)} className={cn( buttonVariants({ variant: isActive ? "default" : "ghost", size: "sm", }), "justify-start transition-all duration-200 hover:translate-x-0.5", isActive && "dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white", )} > <link.icon className="mr-2 size-4" /> {link.title} {link.label && ( <span className={cn( "ml-auto text-xs", isActive && "text-background dark:text-white", )} > {link.label} </span> )} </button> ); })} </nav> </div> ); } ``` #### 1.3 Collapsible Sidebar with Persistent State The sidebar state is already managed by the ResizablePanel's `onCollapse` and `onExpand` callbacks, which save to cookies. For localStorage persistence, enhance this: ```tsx // In MailLayout.tsx, add this effect: React.useEffect(() => { // Load collapsed state from localStorage on mount const savedCollapsed = localStorage.getItem('mail-sidebar-collapsed') if (savedCollapsed !== null) { setIsCollapsed(JSON.parse(savedCollapsed)) } }, []) // Update the onCollapse callback: onCollapse={() => { setIsCollapsed(true) localStorage.setItem('mail-sidebar-collapsed', 'true') document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(true)}` }} // Update the onExpand callback: onExpand={() => { setIsCollapsed(false) localStorage.setItem('mail-sidebar-collapsed', 'false') document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(false)}` }} ``` #### 1.4 Dynamic Badge Counts Update the MailLayout to use dynamic badge counts: ```tsx // In MailLayout.tsx import { useBadgeCounts } from "./use-mail" export function MailLayout({ ... }: MailLayoutProps) { const badgeCounts = useBadgeCounts() // Update the Nav links to use dynamic counts: <Nav isCollapsed={isCollapsed} links={[ { title: "Inbox", label: badgeCounts.inbox.toString(), icon: Inbox, variant: "default", folder: "inbox", }, { title: "Drafts", label: badgeCounts.drafts.toString(), icon: File, variant: "ghost", folder: "drafts", }, // ... continue for all folders ]} /> } ``` ### 2. MAIL LIST FEATURES #### 2.1 Real-Time Search Implementation Create a search component with debouncing: ```tsx // src/components/mail/MailSearch.tsx import * as React from "react"; import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useMail } from "./use-mail"; export function MailSearch() { const [mail, setMail] = useMail(); const [localQuery, setLocalQuery] = React.useState(mail.searchQuery); // Debounce search updates React.useEffect(() => { const timer = setTimeout(() => { setMail({ ...mail, searchQuery: localQuery }); }, 300); return () => clearTimeout(timer); }, [localQuery]); return ( <div className="relative"> <Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" /> <Input placeholder="Search by name, subject, or content..." className="pl-8" value={localQuery} onChange={(e) => setLocalQuery(e.target.value)} /> </div> ); } ``` Update MailLayout to use the search component: ```tsx // In MailLayout.tsx, replace the search form with: import { MailSearch } from "./MailSearch"; <div className="bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <MailSearch /> </div>; ``` #### 2.2 Enhanced Mail List with Selection Highlighting Update MailList to use filtered mails: ```tsx // src/components/mail/MailList.tsx import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Mail } from "@/data/mail-data"; import { useMail, useFilteredMails } from "./use-mail"; export function MailList() { const [mail, setMail] = useMail(); const [filteredMails] = useFilteredMails(); return ( <ScrollArea className="h-screen"> <div className="flex flex-col gap-2 p-4 pt-0"> {filteredMails.length === 0 ? ( <div className="flex items-center justify-center p-8 text-center text-sm text-muted-foreground"> No emails found </div> ) : ( filteredMails.map((item) => ( <button key={item.id} className={cn( "flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent active:scale-[0.99]", mail.selected === item.id && "bg-muted ring-2 ring-primary/20", )} onClick={() => setMail({ ...mail, selected: item.id, }) } > <div className="flex w-full flex-col gap-1"> <div className="flex items-center"> <div className="flex items-center gap-2"> <div className={cn( "font-semibold", !item.read && "text-primary", )} > {item.name} </div> {!item.read && ( <span className="flex size-2 rounded-full bg-blue-600 animate-pulse" /> )} </div> <div className={cn( "ml-auto text-xs", mail.selected === item.id ? "text-foreground" : "text-muted-foreground", )} > {formatDistanceToNow(new Date(item.date), { addSuffix: true, })} </div> </div> <div className={cn( "text-xs font-medium", !item.read && "text-primary", )} > {item.subject} </div> </div> <div className="line-clamp-2 text-xs text-muted-foreground"> {item.text.substring(0, 300)} </div> {item.labels.length ? ( <div className="flex items-center gap-2"> {item.labels.map((label) => ( <Badge key={label} variant="secondary" className="text-xs"> {label} </Badge> ))} </div> ) : null} </button> )) )} </div> </ScrollArea> ); } ``` #### 2.3 Tab Switching with Filtered Results Update the tabs in MailLayout to filter by read status: ```tsx // In MailLayout.tsx <TabsContent value="all" className="m-0"> <MailList /> </TabsContent> <TabsContent value="unread" className="m-0"> <MailList /> </TabsContent> // But we need to add tab state to our config: // Update use-mail.ts: type Config = { selected: Mail["id"] | null activeFolder: MailFolder searchQuery: string activeTab: "all" | "unread" } const configAtom = atom<Config>({ selected: mails[0].id, activeFolder: "inbox", searchQuery: "", activeTab: "all", }) // Update filteredMailsAtom to consider activeTab: const filteredMailsAtom = atom((get) => { const config = get(configAtom) let filtered = mails // ... existing folder filtering ... // Filter by tab if (config.activeTab === "unread") { filtered = filtered.filter(m => !m.read) } // ... existing search filtering ... return filtered }) // In MailLayout.tsx, connect tabs to state: const [mail, setMail] = useMail() <Tabs value={mail.activeTab} onValueChange={(value) => setMail({ ...mail, activeTab: value as "all" | "unread" })} > ``` ### 3. MAIL DISPLAY FEATURES #### 3.1 Action Buttons with Toast Feedback Install sonner for toast notifications: ```bash npm install sonner ``` Create a toast-enabled mail display: ```tsx // src/components/mail/MailDisplay.tsx import * as React from "react" import { toast } from "sonner" import { Archive, ArchiveX, Trash2, Reply, ReplyAll, Forward, Clock, MoreVertical } from "lucide-react" import { Button } from "@/components/ui/button" import { useMail } from "./use-mail" import { Mail } from "@/data/mail-data" interface MailDisplayProps { mail: Mail | null } export function MailDisplay({ mail: currentMail }: MailDisplayProps) { const [mail, setMail] = useMail() const [replyText, setReplyText] = React.useState("") const handleArchive = () => { if (!currentMail) return // In a real app, this would make an API call toast.success("Email archived", { description: `"${currentMail.subject}" has been moved to archive.`, }) } const handleDelete = () => { if (!currentMail) return toast.success("Email deleted", { description: `"${currentMail.subject}" has been moved to trash.`, }) } const handleJunk = () => { if (!currentMail) return toast.success("Email marked as junk", { description: `"${currentMail.subject}" has been moved to junk.`, }) } const handleReply = (e: React.FormEvent) => { e.preventDefault() if (!replyText.trim()) { toast.error("Reply cannot be empty") return } toast.success("Reply sent", { description: `Your reply to "${currentMail?.name}" has been sent.`, }) setReplyText("") } const handleMarkUnread = () => { if (!currentMail) return toast.info("Marked as unread", { description: `"${currentMail.subject}" has been marked as unread.`, }) } // ... rest of component remains the same but with onClick handlers <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" disabled={!currentMail} onClick={handleArchive}> <Archive className="size-4" /> <span className="sr-only">Archive</span> </Button> </TooltipTrigger> <TooltipContent>Archive</TooltipContent> </Tooltip> // Update the reply form: <form onSubmit={handleReply}> <div className="grid gap-4"> <Textarea className="p-4" placeholder={`Reply ${currentMail?.name}...`} value={replyText} onChange={(e) => setReplyText(e.target.value)} /> <div className="flex items-center"> <Label htmlFor="mute" className="flex items-center gap-2 text-xs font-normal" > <Switch id="mute" aria-label="Mute thread" /> Mute this thread </Label> <Button type="submit" size="sm" className="ml-auto"> Send </Button> </div> </div> </form> } ``` Add the Toaster component to the layout: ```tsx // In MailLayout.tsx import { Toaster } from "sonner" export function MailLayout({ ... }: MailLayoutProps) { return ( <TooltipProvider delayDuration={0}> <Toaster position="top-right" richColors /> <ResizablePanelGroup ...> {/* existing content */} </ResizablePanelGroup> </TooltipProvider> ) } ``` #### 3.2 Snooze Functionality with Calendar Picker Enhance the snooze popover with actual functionality: ```tsx // In MailDisplay.tsx const [snoozeDate, setSnoozeDate] = React.useState<Date>() const handleSnooze = (date: Date) => { if (!currentMail) return toast.success("Email snoozed", { description: `"${currentMail.subject}" will reappear on ${format(date, "PPpp")}.`, }) setSnoozeDate(date) } // Update the snooze buttons: <Button variant="ghost" className="justify-start font-normal" onClick={() => handleSnooze(addHours(today, 4))} > Later today{" "} <span className="ml-auto text-muted-foreground"> {format(addHours(today, 4), "E, h:m b")} </span> </Button> // Update the calendar: <Calendar mode="single" selected={snoozeDate} onSelect={(date) => date && handleSnooze(date)} /> ``` ### 4. MOBILE RESPONSIVE FEATURES #### 4.1 Sheet Component for Mobile Sidebar Create a mobile-responsive wrapper: ```tsx // src/components/mail/MailLayoutMobile.tsx "use client"; import * as React from "react"; import { Menu } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { AccountSwitcher } from "./AccountSwitcher"; import { Nav } from "./Nav"; import { Separator } from "@/components/ui/separator"; import { accounts } from "@/data/mail-data"; interface MobileSidebarProps { navLinks: Array<{ title: string; label?: string; icon: any; variant: "default" | "ghost"; folder: any; }>; secondaryLinks: Array<{ title: string; label?: string; icon: any; variant: "default" | "ghost"; folder: any; }>; } export function MobileSidebar({ navLinks, secondaryLinks, }: MobileSidebarProps) { const [open, setOpen] = React.useState(false); return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger asChild> <Button variant="ghost" size="icon" className="md:hidden"> <Menu className="size-5" /> <span className="sr-only">Toggle menu</span> </Button> </SheetTrigger> <SheetContent side="left" className="w-[280px] p-0"> <div className="flex h-full flex-col"> <div className="flex h-[52px] items-center px-2"> <AccountSwitcher isCollapsed={false} accounts={accounts} /> </div> <Separator /> <Nav isCollapsed={false} links={navLinks} /> <Separator /> <Nav isCollapsed={false} links={secondaryLinks} /> </div> </SheetContent> </Sheet> ); } ``` #### 4.2 Responsive Layout Switching Update MailLayout to be fully responsive: ```tsx // In MailLayout.tsx import { MobileSidebar } from "./MailLayoutMobile" import { useMediaQuery } from "@/hooks/use-media-query" export function MailLayout({ ... }: MailLayoutProps) { const [mail] = useMail() const isMobile = useMediaQuery("(max-width: 768px)") const [showMailDisplay, setShowMailDisplay] = React.useState(false) // On mobile, show mail display as overlay when email is selected React.useEffect(() => { if (isMobile && mail.selected) { setShowMailDisplay(true) } }, [mail.selected, isMobile]) if (isMobile) { return ( <TooltipProvider delayDuration={0}> <Toaster position="top-right" richColors /> <div className="flex h-full flex-col"> {/* Mobile header */} <div className="flex items-center gap-2 border-b px-4 py-2"> <MobileSidebar navLinks={primaryLinks} secondaryLinks={secondaryLinks} /> <h1 className="text-lg font-bold">Inbox</h1> </div> {/* Show either mail list or mail display */} {!showMailDisplay ? ( <> <div className="border-b p-4"> <MailSearch /> </div> <Tabs value={mail.activeTab} onValueChange={(value) => setMail({ ...mail, activeTab: value as "all" | "unread" })}> <TabsList className="mx-4 mt-2"> <TabsTrigger value="all">All mail</TabsTrigger> <TabsTrigger value="unread">Unread</TabsTrigger> </TabsList> <TabsContent value="all" className="m-0"> <MailList /> </TabsContent> <TabsContent value="unread" className="m-0"> <MailList /> </TabsContent> </Tabs> </> ) : ( <div className="flex h-full flex-col"> <Button variant="ghost" size="sm" className="m-2" onClick={() => setShowMailDisplay(false)} > ← Back to list </Button> <MailDisplay mail={mails.find((item) => item.id === mail.selected) || null} /> </div> )} </div> </TooltipProvider> ) } // Desktop layout (existing code) return ( <TooltipProvider delayDuration={0}> {/* existing desktop layout */} </TooltipProvider> ) } ``` Create the media query hook: ```tsx // src/hooks/use-media-query.ts import * as React from "react"; export function useMediaQuery(query: string) { const [matches, setMatches] = React.useState(false); React.useEffect(() => { const media = window.matchMedia(query); if (media.matches !== matches) { setMatches(media.matches); } const listener = () => setMatches(media.matches); media.addEventListener("change", listener); return () => media.removeEventListener("change", listener); }, [matches, query]); return matches; } ``` ### 5. DRAG AND DROP (FUTURE ENHANCEMENT) #### 5.1 Setup @dnd-kit ```bash npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities ``` #### 5.2 Draggable Mail Items ```tsx // src/components/mail/DraggableMailList.tsx import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Mail } from "@/data/mail-data"; function DraggableMailItem({ mail }: { mail: Mail }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: mail.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return ( <div ref={setNodeRef} style={style} {...attributes} {...listeners}> {/* Existing mail item UI */} </div> ); } ``` #### 5.3 Droppable Folder Navigation ```tsx // src/components/mail/DroppableNav.tsx import { useDroppable } from "@dnd-kit/core"; function DroppableFolder({ folder, children, }: { folder: string; children: React.ReactNode; }) { const { isOver, setNodeRef } = useDroppable({ id: folder, }); return ( <div ref={setNodeRef} className={cn("transition-colors", isOver && "bg-accent")} > {children} </div> ); } ``` #### 5.4 Drag Context Wrapper ```tsx // In MailLayout.tsx import { DndContext, DragEndEvent, closestCenter } from "@dnd-kit/core" import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" export function MailLayout({ ... }: MailLayoutProps) { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (!over) return // Handle moving email to folder const mailId = active.id const targetFolder = over.id toast.success("Email moved", { description: `Email moved to ${targetFolder}`, }) // Update mail data (in real app, make API call) } return ( <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd} > {/* existing layout */} </DndContext> ) } ``` ### 6. STATE PERSISTENCE STRATEGIES #### 6.1 LocalStorage for UI Preferences ```tsx // src/lib/storage.ts export const storage = { get: <T>(key: string, defaultValue: T): T => { if (typeof window === 'undefined') return defaultValue const item = localStorage.getItem(key) return item ? JSON.parse(item) : defaultValue }, set: <T>(key: string, value: T): void => { if (typeof window === 'undefined') return localStorage.setItem(key, JSON.stringify(value)) }, remove: (key: string): void => { if (typeof window === 'undefined') return localStorage.removeItem(key) }, } // Usage in components: const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => storage.get('mail-sidebar-collapsed', false) ) React.useEffect(() => { storage.set('mail-sidebar-collapsed', sidebarCollapsed) }, [sidebarCollapsed]) ``` #### 6.2 Cookie Persistence for Panel Sizes Already implemented in the ResizablePanelGroup's `onLayout` callback. #### 6.3 URL State for Navigation ```tsx // src/lib/use-url-state.ts import * as React from "react"; export function useUrlState<T>( key: string, defaultValue: T, ): [T, (value: T) => void] { const [state, setState] = React.useState<T>(() => { if (typeof window === "undefined") return defaultValue; const params = new URLSearchParams(window.location.search); const value = params.get(key); return value ? (JSON.parse(value) as T) : defaultValue; }); const setUrlState = React.useCallback( (value: T) => { setState(value); const params = new URLSearchParams(window.location.search); params.set(key, JSON.stringify(value)); window.history.replaceState( {}, "", `${window.location.pathname}?${params}`, ); }, [key], ); return [state, setUrlState]; } // Usage: const [activeFolder, setActiveFolder] = useUrlState<MailFolder>( "folder", "inbox", ); ``` ### 7. PERFORMANCE OPTIMIZATIONS #### 7.1 Virtualized Mail List for Large Datasets ```bash npm install @tanstack/react-virtual ``` ```tsx // src/components/mail/VirtualMailList.tsx import { useVirtualizer } from "@tanstack/react-virtual"; import * as React from "react"; export function VirtualMailList() { const [filteredMails] = useFilteredMails(); const parentRef = React.useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: filteredMails.length, getScrollElement: () => parentRef.current, estimateSize: () => 100, overscan: 5, }); return ( <div ref={parentRef} className="h-screen overflow-auto"> <div style={{ height: `${virtualizer.getTotalSize()}px`, width: "100%", position: "relative", }} > {virtualizer.getVirtualItems().map((virtualItem) => { const mail = filteredMails[virtualItem.index]; return ( <div key={virtualItem.key} style={{ position: "absolute", top: 0, left: 0, width: "100%", height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > {/* Mail item UI */} </div> ); })} </div> </div> ); } ``` #### 7.2 Memoization for Expensive Computations ```tsx // In MailList.tsx const MailItem = React.memo(({ mail, isSelected, onClick }: MailItemProps) => { // Mail item UI }); // In use-mail.ts import { useMemo } from "react"; export function useFilteredMails() { const [mail] = useMail(); const filtered = useMemo(() => { // Expensive filtering logic }, [mail.activeFolder, mail.searchQuery, mail.activeTab]); return filtered; } ``` #### 7.3 Debounced Search Already implemented in the MailSearch component with a 300ms debounce. ### 8. TESTING INTERACTIVE FEATURES Create a comprehensive test checklist: ```markdown ## Interactive Features Test Checklist ### Navigation - [ ] Click each folder in primary nav - [ ] Verify active state highlights current folder - [ ] Check badge counts update correctly - [ ] Test sidebar collapse/expand - [ ] Verify collapsed state shows icons only - [ ] Test hover tooltips in collapsed mode - [ ] Check persistence across page refreshes ### Mail List - [ ] Click mail items to select - [ ] Verify selection highlighting - [ ] Test unread indicator (blue dot) - [ ] Check "All mail" tab shows all emails - [ ] Check "Unread" tab filters correctly - [ ] Verify empty state message - [ ] Test smooth scrolling ### Search - [ ] Type in search box - [ ] Verify 300ms debounce (no lag) - [ ] Test search by name - [ ] Test search by subject - [ ] Test search by email - [ ] Test search by body content - [ ] Clear search and verify reset ### Mail Display - [ ] Click Archive button - [ ] Verify toast notification appears - [ ] Click Delete button - [ ] Click Junk button - [ ] Test Reply button - [ ] Type reply and submit - [ ] Verify reply sent toast - [ ] Test empty reply validation - [ ] Click snooze presets - [ ] Select date from calendar - [ ] Verify snooze toast with date ### Mobile Responsive - [ ] Open on mobile viewport - [ ] Click hamburger menu - [ ] Verify sidebar slides in - [ ] Click folder from mobile menu - [ ] Select email - [ ] Verify email display overlays list - [ ] Click back button - [ ] Return to list view ### State Persistence - [ ] Collapse sidebar - [ ] Refresh page - [ ] Verify sidebar stays collapsed - [ ] Resize panels - [ ] Refresh page - [ ] Verify panel sizes persist - [ ] Select folder - [ ] Check URL updates (if implemented) ### Performance - [ ] Load page with 1000+ emails - [ ] Scroll through list smoothly - [ ] Type in search without lag - [ ] Switch folders instantly - [ ] No console errors - [ ] No hydration warnings ``` ### 9. ACCESSIBILITY ENHANCEMENTS ```tsx // Add keyboard navigation to mail list <button onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { setMail({ ...mail, selected: item.id }) } }} aria-selected={mail.selected === item.id} aria-label={`Email from ${item.name}: ${item.subject}`} > // Add screen reader announcements import { toast } from "sonner" const announce = (message: string) => { toast.info(message, { duration: 1000 }) // Also update live region const liveRegion = document.getElementById('live-region') if (liveRegion) { liveRegion.textContent = message } } // In layout, add live region: <div id="live-region" className="sr-only" role="status" aria-live="polite" aria-atomic="true" /> ``` ### 10. FINAL IMPLEMENTATION CHECKLIST ```markdown ## Implementation Steps Summary 1. [ ] Update use-mail.ts with enhanced state management 2. [ ] Add MailFolder type and activeFolder state 3. [ ] Implement filteredMailsAtom with folder + search filtering 4. [ ] Add useBadgeCounts hook 5. [ ] Update Nav.tsx with click handlers and active states 6. [ ] Create MailSearch.tsx with debounced search 7. [ ] Update MailList.tsx to use filtered results 8. [ ] Add selection highlighting and empty states 9. [ ] Install sonner: `npm install sonner` 10. [ ] Update MailDisplay.tsx with action handlers 11. [ ] Add toast notifications for all actions 12. [ ] Implement working reply form 13. [ ] Add snooze functionality with calendar 14. [ ] Install sheet component: `npx shadcn@latest add sheet` 15. [ ] Create MobileSidebar component 16. [ ] Add useMediaQuery hook 17. [ ] Update MailLayout.tsx with responsive logic 18. [ ] Add Toaster component to layout 19. [ ] Test all features on desktop 20. [ ] Test all features on mobile 21. [ ] Test state persistence (localStorage + cookies) 22. [ ] Test search functionality 23. [ ] Test folder navigation 24. [ ] Verify accessibility (keyboard nav, screen readers) 25. [ ] Check performance with large datasets 26. [ ] Review console for errors/warnings 27. [ ] Deploy and test in production ``` This comprehensive guide provides everything needed to make the mail app fully interactive with professional-grade features, smooth animations, real-time feedback, and responsive design. Each section includes detailed code snippets and implementation notes that can be followed step-by-step. --- ## PREREQUISITES CONFIGURATION ### 1. Astro Configuration (astro.config.mjs) ```javascript import { defineConfig } from "astro/config"; import react from "@astrojs/react"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ integrations: [ react({ // Enable React children as React nodes (needed for shadcn/ui components) experimentalReactChildren: true, }), ], vite: { plugins: [tailwindcss()], }, }); ``` ### 2. TypeScript Configuration (tsconfig.json) ```json { "extends": "astro/tsconfigs/strict", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react", "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } } ``` ### 3. Tailwind CSS Setup Create `src/styles/globals.css`: ```css @import "tailwindcss"; @theme { --color-background: 0 0% 100%; --color-foreground: 222.2 84% 4.9%; --color-card: 0 0% 100%; --color-card-foreground: 222.2 84% 4.9%; --color-popover: 0 0% 100%; --color-popover-foreground: 222.2 84% 4.9%; --color-primary: 222.2 47.4% 11.2%; --color-primary-foreground: 210 40% 98%; --color-secondary: 210 40% 96.1%; --color-secondary-foreground: 222.2 47.4% 11.2%; --color-muted: 210 40% 96.1%; --color-muted-foreground: 215.4 16.3% 46.9%; --color-accent: 210 40% 96.1%; --color-accent-foreground: 222.2 47.4% 11.2%; --color-destructive: 0 84.2% 60.2%; --color-destructive-foreground: 210 40% 98%; --color-border: 214.3 31.8% 91.4%; --color-input: 214.3 31.8% 91.4%; --color-ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --color-background: 222.2 84% 4.9%; --color-foreground: 210 40% 98%; --color-card: 222.2 84% 4.9%; --color-card-foreground: 210 40% 98%; --color-popover: 222.2 84% 4.9%; --color-popover-foreground: 210 40% 98%; --color-primary: 210 40% 98%; --color-primary-foreground: 222.2 47.4% 11.2%; --color-secondary: 217.2 32.6% 17.5%; --color-secondary-foreground: 210 40% 98%; --color-muted: 217.2 32.6% 17.5%; --color-muted-foreground: 215 20.2% 65.1%; --color-accent: 217.2 32.6% 17.5%; --color-accent-foreground: 210 40% 98%; --color-destructive: 0 62.8% 30.6%; --color-destructive-foreground: 210 40% 98%; --color-border: 217.2 32.6% 17.5%; --color-input: 217.2 32.6% 17.5%; --color-ring: 212.7 26.8% 83.9%; } * { border-color: hsl(var(--color-border)); } body { background: hsl(var(--color-background)); color: hsl(var(--color-foreground)); } ``` ### 4. Package Dependencies Install required packages: ```bash # Core packages npm install react@latest react-dom@latest @types/react@latest @types/react-dom@latest # State management npm install jotai # Utilities npm install date-fns lucide-react clsx tailwind-merge # shadcn/ui components - install these: npx shadcn@latest add button npx shadcn@latest add input npx shadcn@latest add tabs npx shadcn@latest add resizable npx shadcn@latest add separator npx shadcn@latest add tooltip npx shadcn@latest add badge npx shadcn@latest add scroll-area npx shadcn@latest add avatar npx shadcn@latest add dropdown-menu npx shadcn@latest add popover npx shadcn@latest add calendar npx shadcn@latest add switch npx shadcn@latest add textarea npx shadcn@latest add label npx shadcn@latest add select ``` Create `src/lib/utils.ts` if not exists: ```typescript import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` --- ## FILE STRUCTURE TO CREATE ``` src/ ├── components/ │ ├── ui/ # shadcn/ui components (auto-generated) │ │ ├── button.tsx │ │ ├── input.tsx │ │ ├── tabs.tsx │ │ ├── resizable.tsx │ │ ├── separator.tsx │ │ ├── tooltip.tsx │ │ ├── badge.tsx │ │ ├── scroll-area.tsx │ │ ├── avatar.tsx │ │ ├── dropdown-menu.tsx │ │ ├── popover.tsx │ │ ├── calendar.tsx │ │ ├── switch.tsx │ │ ├── textarea.tsx │ │ ├── label.tsx │ │ └── select.tsx │ └── mail/ │ ├── MailLayout.tsx # Main layout wrapper (React) │ ├── AccountSwitcher.tsx # Account selector │ ├── Nav.tsx # Sidebar navigation │ ├── MailList.tsx # Mail list component │ ├── MailDisplay.tsx # Mail content display │ └── use-mail.ts # State management hook ├── data/ │ └── mail-data.ts # Mock data ├── layouts/ │ └── BaseLayout.astro ├── pages/ │ └── mail.astro # Mail page └── styles/ └── globals.css ``` --- ## STEP-BY-STEP IMPLEMENTATION ### STEP 1: Create Mail Data (src/data/mail-data.ts) ```typescript export interface Mail { id: string; name: string; email: string; subject: string; text: string; date: string; read: boolean; labels: string[]; } export interface Account { label: string; email: string; icon: React.ReactNode; } export const accounts: Account[] = [ { label: "Alicia Koch", email: "alicia@example.com", icon: null, }, { label: "Alicia Koch", email: "alicia@gmail.com", icon: null, }, { label: "Alicia Koch", email: "alicia@me.com", icon: null, }, ]; export const mails: Mail[] = [ { id: "6c84fb90-12c4-11e1-840d-7b25c5ee775a", name: "William Smith", email: "williamsmith@example.com", subject: "Meeting Tomorrow", text: "Hi, let's have a meeting tomorrow to discuss the project. I've been reviewing the project details and have some ideas I'd like to share. It's crucial that we align on our next steps to ensure the project's success.", date: "2023-10-22T09:00:00", read: true, labels: ["meeting", "work", "important"], }, { id: "110e8400-e29b-11d4-a716-446655440000", name: "Alice Smith", email: "alicesmith@example.com", subject: "Re: Project Update", text: "Thank you for the project update. It looks great! I've gone through the report, and the progress is impressive. The team has done a fantastic job, and I appreciate the hard work everyone has put in.", date: "2023-10-22T10:30:00", read: true, labels: ["work", "important"], }, { id: "3e7c3f6d-bdf5-46ae-8d90-171300f27ae2", name: "Bob Johnson", email: "bobjohnson@example.com", subject: "Weekend Plans", text: "Any plans for the weekend? I was thinking of going hiking in the nearby mountains. It's been a while since we had some outdoor fun.", date: "2023-04-10T11:45:00", read: true, labels: ["personal"], }, { id: "61c35085-72d7-42b4-8d62-738f700d4b92", name: "Emily Davis", email: "emilydavis@example.com", subject: "Re: Question about Budget", text: "I have a question about the budget for the upcoming project. It seems like there's a discrepancy in the allocation of resources.", date: "2023-03-25T13:15:00", read: false, labels: ["work", "budget"], }, { id: "8f7b5c3d-6a9e-4f5d-a329-35f5a8d80607", name: "Michael Wilson", email: "michaelwilson@example.com", subject: "Important Announcement", text: "I have an important announcement to make during our next meeting. It pertains to a strategic shift in our approach to the upcoming quarter.", date: "2023-03-10T15:00:00", read: false, labels: ["meeting", "work", "important"], }, { id: "1f0f2c02-e299-40de-9b1d-86ef9e42126b", name: "Sarah Brown", email: "sarahbrown@example.com", subject: "Re: Feedback on Proposal", text: "Thank you for your feedback on the proposal. I'm pleased to hear that you found it promising. I've made some revisions based on your suggestions.", date: "2023-02-15T16:30:00", read: true, labels: ["work"], }, ]; ``` ### STEP 2: Create State Management Hook (src/components/mail/use-mail.ts) ```typescript import { atom, useAtom } from "jotai"; import { Mail, mails } from "@/data/mail-data"; type Config = { selected: Mail["id"] | null; }; const configAtom = atom<Config>({ selected: mails[0].id, }); export function useMail() { return useAtom(configAtom); } ``` ### STEP 3: Create Navigation Component (src/components/mail/Nav.tsx) ```tsx import { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; interface NavProps { isCollapsed: boolean; links: { title: string; label?: string; icon: LucideIcon; variant: "default" | "ghost"; }[]; } export function Nav({ links, isCollapsed }: NavProps) { return ( <div data-collapsed={isCollapsed} className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2" > <nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"> {links.map((link, index) => isCollapsed ? ( <Tooltip key={index} delayDuration={0}> <TooltipTrigger asChild> <a href="#" className={cn( buttonVariants({ variant: link.variant, size: "icon" }), "h-9 w-9", link.variant === "default" && "dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white", )} > <link.icon className="size-4" /> <span className="sr-only">{link.title}</span> </a> </TooltipTrigger> <TooltipContent side="right" className="flex items-center gap-4"> {link.title} {link.label && ( <span className="ml-auto text-muted-foreground"> {link.label} </span> )} </TooltipContent> </Tooltip> ) : ( <a key={index} href="#" className={cn( buttonVariants({ variant: link.variant, size: "sm" }), link.variant === "default" && "dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white", "justify-start", )} > <link.icon className="mr-2 size-4" /> {link.title} {link.label && ( <span className={cn( "ml-auto", link.variant === "default" && "text-background dark:text-white", )} > {link.label} </span> )} </a> ), )} </nav> </div> ); } ``` ### STEP 4: Create Account Switcher (src/components/mail/AccountSwitcher.tsx) ```tsx import * as React from "react"; import { ChevronsUpDown, Plus } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; export function AccountSwitcher({ accounts, isCollapsed, }: { accounts: { label: string; email: string; icon: React.ReactNode; }[]; isCollapsed: boolean; }) { const [selectedAccount, setSelectedAccount] = React.useState(accounts[0]); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <button className={cn( "flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0", "w-full px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground rounded-md", isCollapsed && "justify-center px-2", )} > {!isCollapsed && ( <> <div className="flex flex-1 flex-col text-left text-sm leading-tight"> <span className="truncate font-semibold"> {selectedAccount.label} </span> <span className="truncate text-xs text-muted-foreground"> {selectedAccount.email} </span> </div> <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" /> </> )} {isCollapsed && ( <span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> {selectedAccount.label[0]} </span> )} </button> </DropdownMenuTrigger> <DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" align="start" side={isCollapsed ? "right" : "bottom"} sideOffset={4} > <DropdownMenuLabel className="text-xs text-muted-foreground"> Accounts </DropdownMenuLabel> {accounts.map((account, index) => ( <DropdownMenuItem key={account.email} onClick={() => setSelectedAccount(account)} className="gap-2 p-2" > <div className="flex size-6 items-center justify-center rounded-sm border"> {account.label[0]} </div> <div className="flex flex-col"> <div className="line-clamp-1 font-medium">{account.label}</div> <div className="line-clamp-1 text-xs text-muted-foreground"> {account.email} </div> </div> </DropdownMenuItem> ))} <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 p-2"> <div className="flex size-6 items-center justify-center rounded-md border bg-background"> <Plus className="size-4" /> </div> <div className="font-medium text-muted-foreground">Add account</div> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); } ``` ### STEP 5: Create Mail List Component (src/components/mail/MailList.tsx) ```tsx import { formatDistanceToNow } from "date-fns"; import { ComponentProps } from "react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Mail } from "@/data/mail-data"; imp