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>
{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>
<h2 className="text-xl font-semibold">{selectedEmail.subject || '(No subject)'}</h2>
<div className="text-sm text-muted-foreground">
<h2 className="text-xl font-semibold text-gray-900">{selectedEmail.subject || '(No subject)'}</h2>
<div className="text-sm text-gray-500">
From: {selectedEmail.from?.[0]?.name || selectedEmail.from?.[0]?.address || 'Unknown'}
</div>
</div>

View File

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

View File

@ -34,7 +34,7 @@ export default function EmailContent({ email }: EmailContentProps) {
setContent(
<div
className="email-content prose prose-sm max-w-none dark:prose-invert"
className="email-content prose max-w-none"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
dir="auto"
/>
@ -58,30 +58,16 @@ export default function EmailContent({ email }: EmailContentProps) {
}
return (
<div className="border-t mt-4 pt-4">
<h3 className="text-sm font-medium mb-2 flex items-center gap-1">
<Paperclip className="h-4 w-4" />
Attachments ({email.attachments.length})
</h3>
<div className="flex flex-wrap gap-2">
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<div className="grid grid-cols-2 gap-4">
{email.attachments.map((attachment, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="flex items-center gap-1 text-xs"
asChild
>
<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 key={index} className="flex items-center space-x-2 p-2 border rounded">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600 truncate">
{attachment.filename}
</span>
</div>
))}
</div>
</div>
@ -91,7 +77,7 @@ export default function EmailContent({ email }: EmailContentProps) {
if (isLoading) {
return (
<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>
);
}
@ -106,7 +92,7 @@ export default function EmailContent({ email }: EmailContentProps) {
}
return (
<div className="p-4">
<div className="p-6">
{content}
{renderAttachments()}
</div>

View File

@ -40,7 +40,7 @@ export default function EmailHeader({
};
return (
<div className="border-b flex flex-col">
<div className="border-b bg-white/95 backdrop-blur-sm flex flex-col">
{/* Courrier Title */}
<div className="p-3 border-b border-gray-100">
<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="flex-1">
<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
type="text"
placeholder="Search emails..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-9"
className="pl-8 pr-8 h-9 bg-gray-50"
/>
{searchQuery && (
<button
@ -66,7 +66,7 @@ export default function EmailHeader({
onClick={clearSearch}
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>
)}
</form>
@ -80,7 +80,7 @@ export default function EmailHeader({
type="submit"
size="icon"
variant="ghost"
className="h-8 w-8"
className="h-8 w-8 text-gray-600 hover:text-gray-900"
onClick={handleSearch}
>
<Search className="h-4 w-4" />
@ -95,7 +95,7 @@ export default function EmailHeader({
<Tooltip>
<TooltipTrigger 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" />
</Button>
</DropdownMenuTrigger>

View File

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

View File

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

View File

@ -95,64 +95,69 @@ export default function EmailListItem({
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 (
<div
className={cn(
'flex items-start gap-2 p-3 cursor-pointer transition-colors hover:bg-accent/50 relative',
isActive && 'bg-accent',
!email.read && 'font-medium'
'flex items-center gap-3 px-4 py-2 hover:bg-gray-50/80 cursor-pointer',
isActive ? 'bg-blue-50/50' : '',
!email.read ? 'bg-blue-50/20' : ''
)}
onClick={onSelect}
>
<div className="flex items-center gap-2 min-w-[3rem]">
<Checkbox
checked={isSelected}
onClick={onToggleSelect}
className="mt-1"
/>
<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>
<Checkbox
checked={isSelected}
onClick={onToggleSelect}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline mb-1">
<div className="font-medium truncate">
{getSenderName()}
<div className="flex items-center justify-between gap-2">
<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()}
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap ml-2">
{formatDate(email.date)}
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-gray-500 whitespace-nowrap">
{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 className="flex items-baseline">
<div className="font-medium truncate">
{email.subject || '(No subject)'}
</div>
{email.hasAttachments && (
<Badge variant="outline" className="ml-2 text-xs">📎</Badge>
)}
</div>
<h3 className="text-sm text-gray-900 truncate">
{email.subject || '(No subject)'}
</h3>
<div className="text-sm text-muted-foreground truncate mt-1">
{email.preview || 'No preview available'}
<div className="text-xs text-gray-500 truncate">
{getPreviewText()}
</div>
</div>
{!email.read && (
<div className="absolute right-3 top-3">
<div className="h-2 w-2 rounded-full bg-primary"></div>
</div>
)}
</div>
);
}

View File

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