courrier refactor

This commit is contained in:
alma 2025-04-26 23:06:39 +02:00
parent 367b79bf0b
commit b056438814
8 changed files with 203 additions and 236 deletions

View File

@ -305,11 +305,11 @@ export default function CourrierPage() {
</div> </div>
{selectedEmail && ( {selectedEmail && (
<div className="flex-1 h-full overflow-hidden flex flex-col"> <div className="flex-1 h-full overflow-hidden flex flex-col bg-white/95 backdrop-blur-sm">
<div className="border-b p-3 flex items-center justify-between"> <div className="border-b p-3 flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold">{selectedEmail.subject || '(No subject)'}</h2> <h2 className="text-xl font-semibold text-gray-900">{selectedEmail.subject || '(No subject)'}</h2>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-gray-500">
From: {selectedEmail.from?.[0]?.name || selectedEmail.from?.[0]?.address || 'Unknown'} From: {selectedEmail.from?.[0]?.name || selectedEmail.from?.[0]?.address || 'Unknown'}
</div> </div>
</div> </div>

View File

@ -1,14 +1,8 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Trash, Mail, MailOpen, Archive } from 'lucide-react'; import { Trash2, EyeOff, Archive } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
interface BulkActionsToolbarProps { interface BulkActionsToolbarProps {
selectedCount: number; selectedCount: number;
@ -20,74 +14,41 @@ export default function BulkActionsToolbar({
onBulkAction onBulkAction
}: BulkActionsToolbarProps) { }: BulkActionsToolbarProps) {
return ( return (
<div className="border-b p-2 flex items-center gap-2 bg-accent/20"> <div className="bg-white border-b border-gray-100 px-4 py-2">
<div className="text-xs font-medium flex-1"> <div className="flex items-center gap-2 mb-2">
{selectedCount} {selectedCount === 1 ? 'message' : 'messages'} selected <span className="text-sm text-gray-600">
{selectedCount} selected
</span>
</div> </div>
<div className="flex items-center gap-1.5">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-8 w-8" className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => onBulkAction('mark-read')} onClick={() => onBulkAction('mark-read')}
> >
<MailOpen className="h-4 w-4" /> <EyeOff className="h-4 w-4 mr-1" />
<span className="text-sm">Mark Read</span>
</Button> </Button>
</TooltipTrigger>
<TooltipContent>Mark as read</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-8 w-8" className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => onBulkAction('mark-unread')}
>
<Mail className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as unread</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onBulkAction('archive')} onClick={() => onBulkAction('archive')}
> >
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4 mr-1" />
<span className="text-sm">Archive</span>
</Button> </Button>
</TooltipTrigger>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-8 w-8 text-destructive hover:text-destructive" className="text-red-600 hover:text-red-700 h-8 px-2"
onClick={() => onBulkAction('delete')} onClick={() => onBulkAction('delete')}
> >
<Trash className="h-4 w-4" /> <Trash2 className="h-4 w-4 mr-1" />
<span className="text-sm">Delete</span>
</Button> </Button>
</TooltipTrigger> </div>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
); );
} }

View File

@ -34,7 +34,7 @@ export default function EmailContent({ email }: EmailContentProps) {
setContent( setContent(
<div <div
className="email-content prose prose-sm max-w-none dark:prose-invert" className="email-content prose max-w-none"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }} dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
dir="auto" dir="auto"
/> />
@ -58,30 +58,16 @@ export default function EmailContent({ email }: EmailContentProps) {
} }
return ( return (
<div className="border-t mt-4 pt-4"> <div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-medium mb-2 flex items-center gap-1"> <h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<Paperclip className="h-4 w-4" /> <div className="grid grid-cols-2 gap-4">
Attachments ({email.attachments.length})
</h3>
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, index) => ( {email.attachments.map((attachment, index) => (
<Button <div key={index} className="flex items-center space-x-2 p-2 border rounded">
key={index} <Paperclip className="h-4 w-4 text-gray-400" />
variant="outline" <span className="text-sm text-gray-600 truncate">
size="sm" {attachment.filename}
className="flex items-center gap-1 text-xs" </span>
asChild </div>
>
<a
href={`/api/courrier/${email.id}/attachment/${encodeURIComponent(attachment.filename)}`}
download={attachment.filename}
target="_blank"
rel="noopener noreferrer"
>
<FileDown className="h-3 w-3 mr-1" />
{attachment.filename} ({(attachment.size / 1024).toFixed(0)}KB)
</a>
</Button>
))} ))}
</div> </div>
</div> </div>
@ -91,7 +77,7 @@ export default function EmailContent({ email }: EmailContentProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center h-full p-8"> <div className="flex justify-center items-center h-full p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
@ -106,7 +92,7 @@ export default function EmailContent({ email }: EmailContentProps) {
} }
return ( return (
<div className="p-4"> <div className="p-6">
{content} {content}
{renderAttachments()} {renderAttachments()}
</div> </div>

View File

@ -40,7 +40,7 @@ export default function EmailHeader({
}; };
return ( return (
<div className="border-b flex flex-col"> <div className="border-b bg-white/95 backdrop-blur-sm flex flex-col">
{/* Courrier Title */} {/* Courrier Title */}
<div className="p-3 border-b border-gray-100"> <div className="p-3 border-b border-gray-100">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -52,13 +52,13 @@ export default function EmailHeader({
<div className="px-4 py-2 flex items-center"> <div className="px-4 py-2 flex items-center">
<div className="flex-1"> <div className="flex-1">
<form onSubmit={handleSearch} className="relative"> <form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
type="text" type="text"
placeholder="Search emails..." placeholder="Search emails..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-9" className="pl-8 pr-8 h-9 bg-gray-50"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@ -66,7 +66,7 @@ export default function EmailHeader({
onClick={clearSearch} onClick={clearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2" className="absolute right-2 top-1/2 transform -translate-y-1/2"
> >
<X className="h-4 w-4 text-muted-foreground" /> <X className="h-4 w-4 text-gray-400" />
</button> </button>
)} )}
</form> </form>
@ -80,7 +80,7 @@ export default function EmailHeader({
type="submit" type="submit"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8" className="h-8 w-8 text-gray-600 hover:text-gray-900"
onClick={handleSearch} onClick={handleSearch}
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
@ -95,7 +95,7 @@ export default function EmailHeader({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600 hover:text-gray-900">
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2, Mail } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Email } from '@/hooks/use-courrier'; import { Email } from '@/hooks/use-courrier';
import EmailListItem from './EmailListItem'; import EmailListItem from './EmailListItem';
@ -57,8 +57,8 @@ export default function EmailList({
// Render loading state // Render loading state
if (isLoading && emails.length === 0) { if (isLoading && emails.length === 0) {
return ( return (
<div className="flex justify-center items-center h-full p-8"> <div className="flex justify-center items-center h-full p-8 bg-white/95 backdrop-blur-sm">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
@ -66,12 +66,12 @@ export default function EmailList({
// Render empty state // Render empty state
if (emails.length === 0) { if (emails.length === 0) {
return ( return (
<div className="flex flex-col justify-center items-center h-full p-8 text-center"> <div className="flex flex-col justify-center items-center h-full p-8 text-center bg-white/95 backdrop-blur-sm">
<h3 className="text-lg font-semibold mb-2">No emails found</h3> <Mail className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-muted-foreground"> <p className="text-gray-500 text-sm">
{currentFolder === 'INBOX' {currentFolder === 'INBOX'
? "Your inbox is empty. You're all caught up!" ? "Your inbox is empty. You're all caught up!"
: `The ${currentFolder} folder is empty.`} : `No emails in this folder`}
</p> </p>
</div> </div>
); );
@ -84,11 +84,13 @@ export default function EmailList({
const someSelected = selectedEmailIds.length > 0 && selectedEmailIds.length < emails.length; const someSelected = selectedEmailIds.length > 0 && selectedEmailIds.length < emails.length;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full bg-white/95 backdrop-blur-sm">
<EmailListHeader <EmailListHeader
allSelected={allSelected} allSelected={allSelected}
someSelected={someSelected} someSelected={someSelected}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
currentFolder={currentFolder}
totalEmails={totalEmails}
/> />
{selectedEmailIds.length > 0 && ( {selectedEmailIds.length > 0 && (
@ -98,8 +100,11 @@ export default function EmailList({
/> />
)} )}
<ScrollArea className="flex-1" onScroll={handleScroll}> <div
<div className="divide-y"> className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="divide-y divide-gray-100">
{emails.map((email) => ( {emails.map((email) => (
<EmailListItem <EmailListItem
key={email.id} key={email.id}
@ -107,11 +112,11 @@ export default function EmailList({
isSelected={selectedEmailIds.includes(email.id)} isSelected={selectedEmailIds.includes(email.id)}
isActive={selectedEmail?.id === email.id} isActive={selectedEmail?.id === email.id}
onSelect={() => onSelectEmail(email.id)} onSelect={() => onSelectEmail(email.id)}
onToggleSelect={(e) => { onToggleSelect={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onToggleSelect(email.id); onToggleSelect(email.id);
}} }}
onToggleStarred={(e) => { onToggleStarred={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onToggleStarred(email.id); onToggleStarred(email.id);
}} }}
@ -119,12 +124,12 @@ export default function EmailList({
))} ))}
{isLoading && emails.length > 0 && ( {isLoading && emails.length > 0 && (
<div className="p-4 flex justify-center"> <div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" /> <div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
)} )}
</div> </div>
</ScrollArea> </div>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown, Inbox } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -15,16 +15,21 @@ interface EmailListHeaderProps {
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
onToggleSelectAll: () => void; onToggleSelectAll: () => void;
currentFolder?: string;
totalEmails?: number;
} }
export default function EmailListHeader({ export default function EmailListHeader({
allSelected, allSelected,
someSelected, someSelected,
onToggleSelectAll, onToggleSelectAll,
currentFolder = 'Inbox',
totalEmails = 0
}: EmailListHeaderProps) { }: EmailListHeaderProps) {
return ( return (
<div className="flex items-center justify-between border-b px-4 py-2"> <div className="border-b border-gray-100 bg-white/95">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-4 h-14">
<div className="flex items-center gap-3">
<Checkbox <Checkbox
checked={allSelected} checked={allSelected}
ref={(input) => { ref={(input) => {
@ -32,28 +37,15 @@ export default function EmailListHeader({
(input as unknown as HTMLInputElement).indeterminate = someSelected && !allSelected; (input as unknown as HTMLInputElement).indeterminate = someSelected && !allSelected;
} }
}} }}
onClick={onToggleSelectAll} onCheckedChange={onToggleSelectAll}
className="mt-0.5"
/> />
<h2 className="text-base font-semibold text-gray-900 capitalize">{currentFolder.toLowerCase()}</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={onToggleSelectAll}>
{allSelected ? 'Unselect all' : 'Select all'}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleSelectAll} disabled={!someSelected}>
Unselect all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div className="text-sm text-muted-foreground"> <span className="text-sm text-gray-600">
Select messages to perform actions {totalEmails} emails
</span>
</div> </div>
</div> </div>
); );

View File

@ -95,64 +95,69 @@ export default function EmailListItem({
return `hsl(${h}, 70%, 80%)`; return `hsl(${h}, 70%, 80%)`;
}; };
// Get preview text from email content
const getPreviewText = () => {
if (email.preview) return email.preview;
let content = email.content || '';
// Strip HTML tags if present
content = content.replace(/<[^>]+>/g, ' ');
// Clean up whitespace
content = content.replace(/\s+/g, ' ').trim();
// Limit to ~70 chars
if (content.length > 70) {
return content.substring(0, 70) + '...';
}
return content || 'No preview available';
};
return ( return (
<div <div
className={cn( className={cn(
'flex items-start gap-2 p-3 cursor-pointer transition-colors hover:bg-accent/50 relative', 'flex items-center gap-3 px-4 py-2 hover:bg-gray-50/80 cursor-pointer',
isActive && 'bg-accent', isActive ? 'bg-blue-50/50' : '',
!email.read && 'font-medium' !email.read ? 'bg-blue-50/20' : ''
)} )}
onClick={onSelect} onClick={onSelect}
> >
<div className="flex items-center gap-2 min-w-[3rem]">
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onClick={onToggleSelect} onClick={onToggleSelect}
className="mt-1" className="mt-0.5"
/> />
<div onClick={onToggleStarred}>
<Star
className={cn(
'h-5 w-5 cursor-pointer transition-colors',
email.starred ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground hover:text-yellow-400'
)}
/>
</div>
</div>
<Avatar className="mt-1 h-8 w-8 font-semibold text-sm" style={{ backgroundColor: getAvatarColor() }}>
<AvatarFallback>{getSenderInitial()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline mb-1"> <div className="flex items-center justify-between gap-2">
<div className="font-medium truncate"> <div className="flex items-center gap-2 min-w-0">
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
{getSenderName()} {getSenderName()}
</span>
</div> </div>
<div className="text-xs text-muted-foreground whitespace-nowrap ml-2"> <div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-gray-500 whitespace-nowrap">
{formatDate(email.date)} {formatDate(email.date)}
</span>
<button
className="h-6 w-6 text-gray-400 hover:text-yellow-400"
onClick={onToggleStarred}
>
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
</div> </div>
</div> </div>
<div className="flex items-baseline"> <h3 className="text-sm text-gray-900 truncate">
<div className="font-medium truncate">
{email.subject || '(No subject)'} {email.subject || '(No subject)'}
</div> </h3>
{email.hasAttachments && (
<Badge variant="outline" className="ml-2 text-xs">📎</Badge>
)}
</div>
<div className="text-sm text-muted-foreground truncate mt-1"> <div className="text-xs text-gray-500 truncate">
{email.preview || 'No preview available'} {getPreviewText()}
</div> </div>
</div> </div>
{!email.read && (
<div className="absolute right-3 top-3">
<div className="h-2 w-2 rounded-full bg-primary"></div>
</div>
)}
</div> </div>
); );
} }

View File

@ -65,23 +65,23 @@ export default function EmailSidebar({
); );
return ( return (
<aside className="w-60 border-r h-full flex flex-col"> <aside className="w-60 border-r h-full flex flex-col bg-white/95 backdrop-blur-sm">
<div className="p-4"> <div className="p-2 border-b border-gray-100">
<Button <Button
className="w-full gap-2" className="w-full gap-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center transition-all py-1.5 text-sm"
size="sm" size="sm"
onClick={onCompose} onClick={onCompose}
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
Compose Compose
</Button> </Button>
</div> </div>
<div className="px-4 py-2"> <div className="px-2 py-2 border-b border-gray-100 flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
className="w-full justify-start gap-2" className="text-gray-600 hover:text-gray-900 hover:bg-gray-100 h-8 w-8"
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
> >
@ -89,48 +89,66 @@ export default function EmailSidebar({
"h-4 w-4", "h-4 w-4",
isLoading && "animate-spin" isLoading && "animate-spin"
)} /> )} />
Refresh
</Button> </Button>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 p-2"> <div className="p-3">
<div className="text-xs font-medium text-muted-foreground px-2 py-1"> <div className="text-sm font-medium text-gray-500 mb-2">
FOLDERS Folders
</div> </div>
{/* Standard folders */} {/* Standard folders */}
<ul className="space-y-0.5 px-2">
{visibleStandardFolders.map(folder => ( {visibleStandardFolders.map(folder => (
<li key={folder}>
<Button <Button
key={folder}
variant={currentFolder === folder ? "secondary" : "ghost"} variant={currentFolder === folder ? "secondary" : "ghost"}
size="sm" className={`w-full justify-start py-2 ${
className="w-full justify-start gap-2" currentFolder === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => onFolderChange(folder)} onClick={() => onFolderChange(folder)}
> >
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
{getFolderIcon(folder)} {getFolderIcon(folder)}
<span className="capitalize">{folder.toLowerCase()}</span> <span className="ml-2 capitalize">{folder.toLowerCase()}</span>
</div>
{folder === 'INBOX' && (
<span className="ml-auto bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{/* Unread count would go here */}
</span>
)}
</div>
</Button> </Button>
</li>
))} ))}
</ul>
{/* Custom folders section */} {/* Custom folders section */}
{customFolders.length > 0 && ( {customFolders.length > 0 && (
<> <>
<div className="text-xs font-medium text-muted-foreground px-2 py-1 mt-4"> <div className="text-sm font-medium text-gray-500 mt-4 mb-2">
CUSTOM FOLDERS Custom Folders
</div> </div>
<ul className="space-y-0.5 px-2">
{customFolders.map(folder => ( {customFolders.map(folder => (
<li key={folder}>
<Button <Button
key={folder}
variant={currentFolder === folder ? "secondary" : "ghost"} variant={currentFolder === folder ? "secondary" : "ghost"}
size="sm" className={`w-full justify-start py-2 ${
className="w-full justify-start gap-2" currentFolder === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => onFolderChange(folder)} onClick={() => onFolderChange(folder)}
> >
<div className="flex items-center">
{getFolderIcon(folder)} {getFolderIcon(folder)}
<span className="truncate">{folder}</span> <span className="ml-2 truncate">{folder}</span>
</div>
</Button> </Button>
</li>
))} ))}
</ul>
</> </>
)} )}
</div> </div>