Initial release: JMAP Webmail Client

A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.

Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+469
View File
@@ -0,0 +1,469 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
interface EmailComposerProps {
onSend?: (data: {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
draftId?: string;
}) => void;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
className?: string;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
receivedAt?: string;
};
}
export function EmailComposer({
onSend,
onClose,
onDiscardDraft,
className,
mode = 'compose',
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
if (mode === 'reply') {
return replyTo.from?.[0]?.email || "";
} else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
return [from, originalTo].filter(Boolean).join(", ");
}
return "";
};
const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return "";
return replyTo.cc?.map(r => r.email).join(", ") || "";
};
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
const getInitialBody = () => {
if (!replyTo?.body) return "";
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown";
if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
}
return "";
};
const [to, setTo] = useState(getInitialTo());
const [cc, setCc] = useState(getInitialCc());
const [bcc, setBcc] = useState("");
const [subject, setSubject] = useState(getInitialSubject());
const [body, setBody] = useState(getInitialBody());
const [showCc, setShowCc] = useState(!!getInitialCc());
const [showBcc, setShowBcc] = useState(false);
const [draftId, setDraftId] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { client } = useAuthStore();
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
const files = Array.from(event.target.files);
// Add files to attachments list with uploading state
const newAttachments = files.map(file => ({ file, uploading: true }));
setAttachments(prev => [...prev, ...newAttachments]);
// Upload each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const { blobId } = await client.uploadBlob(file);
// Update attachment with blobId
setAttachments(prev =>
prev.map(att =>
att.file === file
? { ...att, blobId, uploading: false }
: att
)
);
} catch (error) {
console.error(`Failed to upload ${file.name}:`, error);
// Mark attachment as failed
setAttachments(prev =>
prev.map(att =>
att.file === file
? { ...att, uploading: false, error: true }
: att
)
);
}
}
// Clear the input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Remove attachment
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
// Auto-save draft functionality
const saveDraft = async (): Promise<string | null> => {
if (!client) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Only save if there's some content
if (!toAddresses.length && !subject && !body) {
return null;
}
// Prepare attachments for draft
const uploadedAttachments = attachments
.filter(att => att.blobId && !att.uploading)
.map(att => ({
blobId: att.blobId!,
name: att.file.name,
type: att.file.type,
size: att.file.size,
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
return draftId;
}
setSaveStatus('saving');
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || "(No subject)",
body,
ccAddresses,
bccAddresses,
draftId || undefined,
uploadedAttachments
);
setDraftId(savedDraftId);
lastSavedDataRef.current = currentData;
setSaveStatus('saved');
// Reset status after 2 seconds
setTimeout(() => setSaveStatus('idle'), 2000);
return savedDraftId;
} catch (error) {
console.error('Failed to save draft:', error);
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
return null;
}
};
// Trigger auto-save when content changes
useEffect(() => {
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Don't auto-save if there's no content
if (!to && !subject && !body) {
return;
}
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
saveDraft();
}, 2000);
// Cleanup on unmount
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
}, [to, cc, bcc, subject, body, attachments]);
const handleSend = async () => {
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Allow sending if we have recipient, subject, and either body text or attachments
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
if (toAddresses.length > 0 && subject && hasContent) {
// Wait for any pending auto-save to complete and get the latest draft ID
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
// saveDraft returns the new draft ID after destroy+create
const savedId = await saveDraft();
if (savedId) {
finalDraftId = savedId;
}
}
onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body,
draftId: finalDraftId || undefined,
});
// Reset form
setTo("");
setCc("");
setBcc("");
setSubject("");
setBody("");
setDraftId(null);
}
};
const handleClose = () => {
// If there's a draft with content, ask user if they want to discard
if (draftId && (to || subject || body)) {
const confirmDiscard = window.confirm(t('discard_draft_confirm'));
if (confirmDiscard) {
// Clear any pending auto-save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Delete the draft if callback is provided
if (onDiscardDraft) {
onDiscardDraft(draftId);
}
onClose?.();
}
} else {
onClose?.();
}
};
return (
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold">New Message</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span>Saving...</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span>Draft saved</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span>Failed to save</span>
</div>
)}
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-2 px-4 py-3 border-b">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">To:</span>
<Input
type="email"
placeholder="Recipient email addresses (comma separated)"
value={to}
onChange={(e) => setTo(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs"
>
Cc
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBcc(!showBcc)}
className="text-xs"
>
Bcc
</Button>
</div>
</div>
{showCc && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Cc:</span>
<Input
type="email"
placeholder="Cc recipients (comma separated)"
value={cc}
onChange={(e) => setCc(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
)}
{showBcc && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Bcc:</span>
<Input
type="email"
placeholder="Bcc recipients (comma separated)"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Subject:</span>
<Input
type="text"
placeholder="Subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
</div>
<div className="flex-1 px-4 py-3">
<textarea
className="w-full h-full resize-none outline-none text-sm"
placeholder="Compose email..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
{/* Attachments display */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={index}
className={cn(
"flex items-center gap-2 px-3 py-1 rounded-md text-sm",
att.error ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-700"
)}
>
{att.uploading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : att.error ? (
<AlertCircle className="w-3 h-3" />
) : (
<Paperclip className="w-3 h-3" />
)}
<span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-gray-500">
({(att.file.size / 1024).toFixed(1)} KB)
</span>
<button
onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-600"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t">
<div>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-4 h-4 mr-2" />
Attach
</Button>
</div>
<Button onClick={handleSend}>
<Send className="w-4 h-4 mr-2" />
Send
</Button>
</div>
</div>
</div>
);
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useTranslations } from "next-intl";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Reply,
ReplyAll,
Forward,
Mail,
MailOpen,
Star,
Trash2,
Archive,
FolderInput,
Palette,
X,
Inbox,
Send,
File,
Folder,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Position {
x: number;
y: number;
}
interface EmailContextMenuProps {
email: Email;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
}
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
case "inbox":
return Inbox;
case "sent":
return Send;
case "drafts":
return File;
case "trash":
return Trash2;
case "archive":
return Archive;
default:
return Folder;
}
};
// Get current color from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
}
}
return null;
};
export function EmailContextMenu({
email,
position,
isOpen,
onClose,
menuRef,
mailboxes,
selectedMailbox,
isMultiSelect = false,
selectedCount = 1,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onDelete,
onArchive,
onSetColorTag,
onMoveToMailbox,
onBatchMarkAsRead,
onBatchDelete,
onBatchMoveToMailbox,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
);
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<ContextMenu
ref={menuRef}
isOpen={isOpen}
position={position}
onClose={onClose}
>
{/* Batch header */}
{showBatchActions && (
<ContextMenuHeader>
{t("items_selected", { count: selectedCount })}
</ContextMenuHeader>
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
label={t("reply")}
onClick={() => handleAction(onReply!)}
disabled={!onReply}
/>
<ContextMenuItem
icon={ReplyAll}
label={t("reply_all")}
onClick={() => handleAction(onReplyAll!)}
disabled={!onReplyAll}
/>
<ContextMenuItem
icon={Forward}
label={t("forward")}
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuSeparator />
</>
)}
{/* Mark as read/unread */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
/>
{/* Star/Unstar - only for single email */}
{!showBatchActions && (
<ContextMenuItem
icon={Star}
label={isStarred ? t("unstar") : t("star")}
onClick={() => handleAction(onToggleStar!)}
disabled={!onToggleStar}
/>
)}
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTargets.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
{moveTargets.map((mailbox) => {
const Icon = getMailboxIcon(mailbox.role);
return (
<ContextMenuItem
key={mailbox.id}
icon={Icon}
label={mailbox.name}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(mailbox.id)
: onMoveToMailbox?.(mailbox.id)
)
}
/>
);
})}
</ContextMenuSubMenu>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
/>
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
className={cn(
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
option.color,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
/>
))}
</div>
{currentColor && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
</ContextMenu>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
// Color tag mapping - using lighter backgrounds for better readability
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
const color = key.replace("$color:", "");
return colorTags[color as keyof typeof colorTags] || null;
}
}
return null;
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const sender = email.from?.[0];
const colorTag = getEmailColor(email.keywords);
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
{...dragHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-accent/40",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
Important
</span>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
interface EmailListProps {
emails: Email[];
selectedEmailId?: string;
onEmailSelect?: (email: Email) => void;
className?: string;
isLoading?: boolean;
// Mobile conversation view handler
onOpenConversation?: (thread: ThreadGroup) => void;
// Context menu actions
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
}
export function EmailList({
emails,
selectedEmailId,
onEmailSelect,
className,
isLoading = false,
onOpenConversation,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onDelete,
onArchive,
onSetColorTag,
onMoveToMailbox,
}: EmailListProps) {
const { client } = useAuthStore();
const {
selectedEmailIds,
selectAllEmails,
clearSelection,
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
mailboxes,
selectedMailbox,
expandedThreadIds,
threadEmailsCache,
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
} = useEmailStore();
// Group emails by thread
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails);
return sortThreadGroups(groups);
}, [emails]);
// Context menu state
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const [isProcessing, setIsProcessing] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
// Loading skeleton component - gentler, no pulsing
const LoadingSkeleton = () => (
<div className="animate-in fade-in duration-200">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
const hasSelection = selectedEmailIds.size > 0;
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
await batchMarkAsRead(client, read);
} finally {
setTimeout(() => setIsProcessing(false), 500); // Small delay for visual feedback
}
};
const handleBatchDelete = async () => {
if (!client || isProcessing || !confirm(`Delete ${selectedEmailIds.size} emails?`)) return;
setIsProcessing(true);
try {
await batchDelete(client);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
// Intersection observer for infinite scroll
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
// Handle thread expansion and fetch complete thread
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
if (!isExpanded && client) {
// Expanding - fetch complete thread emails
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
} else {
// Collapsing - just toggle
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
handleLoadMore();
}
},
{ threshold: 0.1 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [handleLoadMore]);
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Batch Actions Toolbar with smooth transition */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(true)}
title="Mark as read"
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<MailOpen className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(false)}
title="Mark as unread"
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Mail className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleBatchDelete}
title="Delete"
disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title="Clear selection"
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
Cancel
</Button>
</div>
</div>
</div>
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
</h2>
</div>
</div>
{/* Email List */}
<div className="flex-1 overflow-y-auto bg-background relative">
{/* Loading overlay - shows on top of existing emails */}
{isLoading && emails.length > 0 && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading...</span>
</div>
</div>
)}
{/* Show skeleton only on initial load (no emails yet) */}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">No emails in this mailbox</p>
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p>
</div>
) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
{threadGroups.map((thread) => (
<ThreadListItem
key={thread.threadId}
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadId)}
selectedEmailId={selectedEmailId}
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
/>
))}
{/* Intersection observer target for infinite scroll - always present */}
<div ref={observerTarget} className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading more emails...</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
No more emails to load
</div>
)}
</div>
</div>
)}
</div>
{/* Context Menu */}
{contextMenu.data && (
<EmailContextMenu
email={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
// Single email actions
onReply={() => onReply?.(contextMenu.data!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
// Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
/>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Reply,
ReplyAll,
Forward,
Paperclip,
Star,
Download,
Loader2,
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
File,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
interface ThreadConversationViewProps {
thread: ThreadGroup;
emails: Email[];
isLoading?: boolean;
onBack: () => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
// Helper function to get file icon based on mime type or extension
const getFileIcon = (name?: string, type?: string) => {
const ext = name?.split('.').pop()?.toLowerCase();
const mimeType = type?.toLowerCase();
if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
return FileImage;
}
if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
return FileVideo;
}
if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
return FileAudio;
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return FileText;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
return FileArchive;
}
return File;
};
export function ThreadConversationView({
thread,
emails,
isLoading = false,
onBack,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
// Auto-expand most recent email AND all unread emails when thread opens
useEffect(() => {
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
idsToExpand.add(email.id);
}
});
setExpandedIds(idsToExpand);
}
}, [emails]);
const toggleExpanded = (emailId: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(emailId)) {
next.delete(emailId);
} else {
next.add(emailId);
}
return next;
});
};
const toggleAllowExternal = (emailId: string) => {
setAllowExternalContent(prev => {
const next = new Set(prev);
next.add(emailId);
return next;
});
};
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("threads.loading")}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10">
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-foreground truncate">
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
<p className="text-sm text-muted-foreground">
{t("threads.messages_other", { count: emails.length })}
</p>
</div>
</div>
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-3">
{emails.map((email, index) => (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContentPolicy === 'allow' || allowExternalContent.has(email.id)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
/>
))}
</div>
</div>
</div>
);
}
// Individual email card component
interface EmailCardProps {
email: Email;
isExpanded: boolean;
isLatest: boolean;
allowExternal: boolean;
onToggleExpanded: () => void;
onAllowExternal: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
function EmailCard({
email,
isExpanded,
isLatest: _isLatest,
allowExternal,
onToggleExpanded,
onAllowExternal,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: EmailCardProps) {
const t = useTranslations();
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
// Mark as read when email is expanded
useEffect(() => {
// Only trigger if expanded, email is unread, and we have a handler
if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
if (email.bodyValues) {
let useHtmlVersion = false;
let htmlContent = '';
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
}
if (useHtmlVersion && htmlContent) {
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
};
if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.removeAttribute('src');
node.setAttribute('alt', '[Image blocked]');
blockedExternalContent = true;
}
}
if (node.hasAttribute('style')) {
const style = node.getAttribute('style');
if (style && /url\s*\(/i.test(style)) {
const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none');
node.setAttribute('style', cleanStyle);
blockedExternalContent = true;
}
}
});
}
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes');
if (blockedExternalContent) {
setHasBlockedContent(true);
}
return { html: sanitized, isHtml: true };
}
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
const htmlEscaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
return { html: htmlEscaped, isHtml: false };
}
}
// Fallback to preview
if (email.preview) {
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
}
return { html: "", isHtml: false };
}, [email, allowExternal]);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-l-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start gap-3 p-4 text-left transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
}}
>
{t("email_viewer.load_external_content")}
</Button>
</div>
)}
{/* Email Body */}
<div className="px-4 py-4">
<div
className={cn(
"prose prose-sm max-w-none dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
return (
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
</div>
)}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-1"
>
<Reply className="w-4 h-4 mr-2" />
{t("email_viewer.reply")}
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-1"
>
<ReplyAll className="w-4 h-4 mr-2" />
{t("email_viewer.reply_all")}
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-1"
>
<Forward className="w-4 h-4 mr-2" />
{t("email_viewer.forward")}
</Button>
)}
</div>
</div>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle } from "lucide-react";
interface ThreadEmailItemProps {
email: Email;
selected?: boolean;
isLast?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
export function ThreadEmailItem({
email,
selected,
isLast = false,
onClick,
onContextMenu,
}: ThreadEmailItemProps) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
className={cn(
"relative cursor-pointer transition-all duration-150",
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
"border-l-2 border-l-transparent",
selected
? "bg-accent border-l-primary"
: "hover:bg-muted/50",
isUnread && !selected && "bg-accent/20",
!isLast && "border-b border-border/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
>
<div className="flex items-start gap-3">
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-7 top-1/2 -translate-y-1/2">
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Small Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="sm"
className="flex-shrink-0"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Single line: Sender, indicators, preview, date */}
<div className="flex items-center gap-2">
<span className={cn(
"truncate text-sm flex-shrink-0 max-w-[150px]",
isUnread
? "font-semibold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email?.split('@')[0] || "Unknown"}
</span>
{/* Indicators */}
<div className="flex items-center gap-1 flex-shrink-0">
{isStarred && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
)}
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
</div>
{/* Preview snippet */}
<span className={cn(
"text-sm truncate flex-1 min-w-0",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/70"
)}>
{email.preview || "No preview"}
</span>
{/* Date */}
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
</div>
</div>
);
}
+379
View File
@@ -0,0 +1,379 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
import { ThreadEmailItem } from "./thread-email-item";
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
selectedEmailId?: string;
isLoading?: boolean;
expandedEmails?: Email[]; // Full thread emails when expanded
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
}
// Color tag mapping
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
export function ThreadListItem({
thread,
isExpanded,
selectedEmailId,
isLoading = false,
expandedEmails,
onToggleExpand,
onEmailSelect,
onContextMenu,
onOpenConversation,
}: ThreadListItemProps) {
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
// Get color tag from thread
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
// Check if latest email is selected
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
// Single email thread - render as regular email, no expand
if (emailCount === 1) {
return (
<SingleEmailItem
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
/>
);
}
// Get emails to display when expanded
const emailsToShow = expandedEmails || thread.emails;
const handleHeaderClick = (e: React.MouseEvent) => {
// Mobile: open conversation view instead of inline expansion
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
}
// Desktop: If clicking directly on the expand icon area, toggle expansion
// Otherwise, select the latest email
const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
// Clicking on the row selects the latest email but also expands
if (!isExpanded) {
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div className="border-b border-border">
{/* Thread Header (collapsed view) */}
<div
className={cn(
"relative group cursor-pointer transition-all duration-200",
colorTag ? colorTag : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Expand/Collapse Button - Hidden on mobile */}
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{/* Unread indicator */}
{hasUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Participants and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
{/* Email count badge */}
<span className={cn(
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
{/* Expanded Thread Emails - Desktop only */}
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading conversation...
</div>
) : (
emailsToShow.map((email, index) => (
<ThreadEmailItem
key={email.id}
email={email}
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onContextMenu={onContextMenu}
/>
))
)}
</div>
)}
</div>
);
}
// Single email item (for threads with only 1 email)
function SingleEmailItem({
email,
selected,
onClick,
onContextMenu,
showPreview,
colorTag,
}: {
email: Email;
selected: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
}) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Spacer for alignment with thread items */}
<div className="w-6 flex-shrink-0" />
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import React, { Component, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { debug } from "@/lib/debug";
export interface FallbackProps {
error: Error;
resetError: () => void;
t: (key: string) => string;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback: (props: FallbackProps) => ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Core error boundary class component (React requirement).
* Receives translation function as prop from the functional wrapper.
*/
class ErrorBoundaryCore extends Component<
ErrorBoundaryProps & { t: (key: string) => string },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Always log errors
debug.error("[ErrorBoundary]", error.message, {
stack: error.stack,
componentStack: errorInfo.componentStack,
});
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
resetError = (): void => {
this.props.onReset?.();
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError && this.state.error) {
return this.props.fallback({
error: this.state.error,
resetError: this.resetError,
t: this.props.t,
});
}
return this.props.children;
}
}
/**
* Functional wrapper that injects translations into the error boundary.
* Use this component to wrap any part of your UI that might throw errors.
*
* @example
* <ErrorBoundary fallback={SidebarErrorFallback}>
* <Sidebar />
* </ErrorBoundary>
*/
export function ErrorBoundary({
children,
fallback,
onError,
onReset,
}: ErrorBoundaryProps) {
const t = useTranslations("errors");
return (
<ErrorBoundaryCore
fallback={fallback}
onError={onError}
onReset={onReset}
t={t}
>
{children}
</ErrorBoundaryCore>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { FallbackProps } from "./error-boundary";
/**
* Full-page error fallback for route-level errors.
*/
export function PageErrorFallback({ error: _error, resetError, t }: FallbackProps) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("try_again")}
</Button>
</div>
</div>
);
}
/**
* Sidebar error fallback - matches sidebar width (256px).
*/
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("sidebar_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-3 h-3 mr-1" />
{t("reload")}
</Button>
</div>
);
}
/**
* Email list error fallback - matches email list panel width (384px).
*/
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("email_list_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("reload_emails")}
</Button>
</div>
);
}
/**
* Email viewer error fallback - fills remaining space (flex-1).
*/
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<Mail className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("viewer_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
{t("viewer_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("try_again")}
</Button>
</div>
);
}
/**
* Email composer modal error fallback.
*/
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
<AlertCircle className="w-10 h-10 text-amber-500 mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("composer_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
{t("retry")}
</Button>
</div>
);
}
/**
* Settings page error fallback.
*/
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{t("settings_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6">
{t("settings_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("reload_settings")}
</Button>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export { ErrorBoundary } from "./error-boundary";
export type { FallbackProps } from "./error-boundary";
export {
PageErrorFallback,
SidebarErrorFallback,
EmailListErrorFallback,
EmailViewerErrorFallback,
ComposerErrorFallback,
SettingsErrorFallback,
} from "./error-fallbacks";
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const modalRef = useRef<HTMLDivElement>(null);
// Close on any key press
useEffect(() => {
const handleKeyDown = () => {
onClose();
};
if (isOpen) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
className={cn(
"bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-2xl max-h-[80vh] overflow-hidden",
"animate-in zoom-in-95 duration-200"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
{t("shortcuts.title")}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Navigation Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.navigation")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.navigation.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Actions Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.actions")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.actions.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Global Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.global")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.global.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Threads Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.threads")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
</div>
{/* Footer tip */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
</div>
</div>
</div>
</div>
);
}
function ShortcutRow({
shortcutKey,
description,
}: {
shortcutKey: string;
description: string;
}) {
// Split keys by " / " to render multiple key badges
const keys = shortcutKey.split(" / ");
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex items-center gap-1.5 ml-4">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
<kbd
className={cn(
"inline-flex items-center justify-center",
"px-2 py-0.5 text-xs font-mono font-medium",
"bg-muted border border-border rounded",
"text-foreground shadow-sm",
"min-w-[24px]"
)}
>
{key}
</kbd>
</span>
))}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
interface MobileHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onCompose?: () => void;
onSearch?: () => void;
className?: string;
}
export function MobileHeader({
title,
showBack = false,
onBack,
onCompose,
onSearch,
className,
}: MobileHeaderProps) {
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
const handleLeftAction = () => {
if (showBack && onBack) {
onBack();
} else if (showBack) {
goBack();
} else {
toggleSidebar();
}
};
return (
<header
className={cn(
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
"md:hidden", // Only visible on mobile
className
)}
>
{/* Left action: Menu or Back button */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleLeftAction}
className="h-10 w-10"
aria-label={showBack ? "Go back" : "Toggle menu"}
>
{showBack ? (
<ArrowLeft className="h-5 w-5" />
) : sidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
{/* Title */}
<h1 className="font-semibold text-lg truncate">{title}</h1>
</div>
{/* Right actions */}
<div className="flex items-center gap-1">
{onSearch && (
<Button
variant="ghost"
size="icon"
onClick={onSearch}
className="h-10 w-10"
aria-label="Search"
>
<Search className="h-5 w-5" />
</Button>
)}
{onCompose && (
<Button
variant="ghost"
size="icon"
onClick={onCompose}
className="h-10 w-10 text-primary"
aria-label="Compose"
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</header>
);
}
/**
* Viewer header for mobile - shows when viewing an email
*/
interface MobileViewerHeaderProps {
subject?: string;
onBack: () => void;
onDelete?: () => void;
onArchive?: () => void;
className?: string;
}
export function MobileViewerHeader({
subject,
onBack,
onDelete: _onDelete,
onArchive: _onArchive,
className,
}: MobileViewerHeaderProps) {
return (
<header
className={cn(
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
"md:hidden",
className
)}
>
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-10 w-10"
aria-label="Go back"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="flex-1 font-medium text-sm truncate px-2 text-center">
{subject || "(No Subject)"}
</h1>
<div className="flex items-center">
{/* Placeholder for additional actions - kept minimal */}
<div className="w-10" />
</div>
</header>
);
}
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Inbox,
Send,
File,
Star,
Trash2,
Archive,
PenSquare,
Search,
Menu,
LogOut,
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Settings,
ChevronUp,
Users,
User,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
interface SidebarProps {
mailboxes: Mailbox[];
selectedMailbox?: string;
onMailboxSelect?: (mailboxId: string) => void;
onCompose?: () => void;
onLogout?: () => void;
onSearch?: (query: string) => void;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
className?: string;
}
// Map role to icon
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || "";
// Shared folders root node
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
// Shared account nodes
if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User;
}
// Shared mailboxes (but not virtual nodes)
if (isShared && hasChildren && !id?.startsWith('shared-')) {
return isExpanded ? FolderOpen : Folder;
}
if (hasChildren) {
// For folders with children, return open/closed folder icon
return isExpanded ? FolderOpen : Folder;
}
if (role === "inbox" || lowerName.includes("inbox")) return Inbox;
if (role === "sent" || lowerName.includes("sent")) return Send;
if (role === "drafts" || lowerName.includes("draft")) return File;
if (role === "trash" || lowerName.includes("trash")) return Trash2;
if (role === "archive" || lowerName.includes("archive")) return Archive;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
return Inbox; // Default icon
};
// Component for rendering a single mailbox node with its children
function MailboxTreeItem({
node,
selectedMailbox,
expandedFolders,
onMailboxSelect,
onToggleExpand,
isCollapsed,
}: {
node: MailboxNode;
selectedMailbox: string;
expandedFolders: Set<string>;
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
}) {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const indentPixels = node.depth * 16; // 16px per depth level
const isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization
// Drag and drop functionality
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
});
return (
<>
<div
{...(globalDragging ? dropHandlers : {})}
className={cn(
"group w-full flex items-center px-2 py-1 text-sm transition-all duration-200",
selectedMailbox === node.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
node.depth === 0 && "font-medium", // Root folders are slightly bolder
// Drop target visual feedback
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
>
{/* Expand/Collapse Chevron */}
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: indentPixels }}
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
{/* Mailbox Button */}
<button
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 px-1 rounded",
"transition-colors duration-150",
isVirtualNode && "cursor-default"
)}
style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
}}
title={isCollapsed ? node.name : undefined}
>
<Icon className={cn(
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground",
node.isShared && "text-blue-500" // Shared folders in blue
)} />
{!isCollapsed && (
<>
<span className="flex-1 truncate">{node.name}</span>
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{node.unreadEmails}
</span>
)}
</>
)}
</button>
</div>
{/* Render children if expanded */}
{hasChildren && isExpanded && !isCollapsed && (
<div className="relative">
{node.children.map((child) => (
<MailboxTreeItem
key={child.id}
node={child}
selectedMailbox={selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
/>
))}
</div>
)}
</>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
onMailboxSelect,
onCompose,
onLogout,
onSearch,
quota,
isPushConnected = false,
className,
}: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const t = useTranslations('sidebar');
const params = useParams();
const router = useRouter();
// Load expanded folders from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
if (stored) {
try {
const parsed = JSON.parse(stored);
setExpandedFolders(new Set(parsed));
} catch (e) {
console.error('Failed to parse expanded mailboxes:', e);
}
} else {
// By default, expand root folders that have children
const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree
.filter(node => node.children.length > 0)
.map(node => node.id);
setExpandedFolders(new Set(defaultExpanded));
}
}, [mailboxes]);
// Save expanded folders to localStorage when changed
const handleToggleExpand = (mailboxId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(mailboxId)) {
next.delete(mailboxId);
} else {
next.add(mailboxId);
}
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
return next;
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim() && onSearch) {
onSearch(searchQuery);
}
};
// Build hierarchical mailbox tree
const mailboxTree = buildMailboxTree(mailboxes);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedMailbox || isCollapsed) return;
// Find the selected node in the tree
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
for (const node of nodes) {
if (node.id === selectedMailbox) return node;
const found = findNode(node.children);
if (found) return found;
}
return null;
};
const selectedNode = findNode(mailboxTree);
if (!selectedNode) return;
// Handle arrow keys for expand/collapse
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
// Expand folder
if (!expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
// Collapse folder
if (expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
return (
<div
className={cn(
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
isCollapsed ? "w-16" : "w-64",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<Menu className="w-5 h-5" />
</Button>
{!isCollapsed && (
<Button onClick={onCompose} className="ml-2 flex-1">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
{/* Search */}
{!isCollapsed && (
<div className="px-4 py-3">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
data-search-input
/>
</form>
</div>
)}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{/* Render hierarchical mailbox tree */}
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
/>
))}
</>
)}
</div>
</div>
{/* Footer */}
{!isCollapsed && (
<>
{/* Sliding Menu Panel */}
<div className={cn(
"absolute bottom-0 left-0 right-0 bg-background border-t border-border z-10 shadow-lg",
"transform transition-all duration-300 ease-out",
showMenu ? "-translate-y-12" : "translate-y-full"
)}>
<div className="py-2">
{/* Storage Info */}
{quota && quota.total > 0 && (
<div className="px-4 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div
className="bg-primary h-1 rounded-full"
style={{ width: `${Math.min((quota.used / quota.total) * 100, 100)}%` }}
/>
</div>
</div>
)}
<div className="border-t border-border mt-2 pt-2">
{/* Settings */}
<button
onClick={() => router.push(`/${params.locale}/settings`)}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<Settings className="w-4 h-4" />
{t("settings")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Sign Out */}
{onLogout && (
<button
onClick={onLogout}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
)}
</div>
</div>
</div>
{/* Menu Toggle Button */}
<div className="border-t border-border relative">
<button
onClick={() => setShowMenu(!showMenu)}
className={cn(
"w-full px-4 py-3 flex items-center justify-between",
"hover:bg-muted transition-colors",
"text-sm text-foreground"
)}
>
<span className="flex items-center gap-2">
<Menu className="w-4 h-4" />
Menu
{/* Push Connection Status Indicator */}
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
{/* Tooltip on hover */}
<span className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
</span>
<ChevronUp className={cn(
"w-4 h-4 transition-transform duration-200",
showMenu ? "" : "rotate-180"
)} />
</button>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
useEffect(() => {
initializeTheme();
}, [initializeTheme]);
return <>{children}</>;
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore();
const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
</SettingsSection>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
const settingsJson = exportSettings();
const blob = new Blob([settingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webmail-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImport = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target?.result as string;
const success = importSettings(json);
if (success) {
alert(t('../../settings.import_success'));
} else {
alert(t('../../settings.import_error'));
}
};
reader.readAsText(file);
};
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
setShowResetConfirm(false);
alert(t('../../settings.save_success'));
} else {
setShowResetConfirm(true);
setTimeout(() => setShowResetConfirm(false), 5000);
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{/* Export Settings */}
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
{/* Import Settings */}
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const { theme, setTheme } = useThemeStore();
const { fontSize, listDensity, animationsEnabled, updateSetting } = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
onChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
options={[
{ value: 'light', label: t('theme.light') },
{ value: 'dark', label: t('theme.dark') },
{ value: 'system', label: t('theme.system') },
]}
/>
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
<RadioGroup
value={fontSize}
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
options={[
{ value: 'small', label: t('font_size.small') },
{ value: 'medium', label: t('font_size.medium') },
{ value: 'large', label: t('font_size.large') },
]}
/>
</SettingItem>
{/* List Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
<RadioGroup
value={listDensity}
onChange={(value) =>
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
}
options={[
{ value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') },
]}
/>
</SettingItem>
{/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}>
<ToggleSwitch
checked={animationsEnabled}
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const {
markAsReadDelay,
deleteAction,
showPreview,
emailsPerPage,
externalContentPolicy,
updateSetting,
} = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
<Select
value={markAsReadDelay.toString()}
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
options={[
{ value: '0', label: t('mark_read.instant') },
{ value: '3000', label: t('mark_read.delay_3s') },
{ value: '5000', label: t('mark_read.delay_5s') },
{ value: '-1', label: t('mark_read.never') },
]}
/>
</SettingItem>
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select
value={emailsPerPage.toString()}
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
options={[
{ value: '25', label: t('emails_per_page.25') },
{ value: '50', label: t('emails_per_page.50') },
{ value: '100', label: t('emails_per_page.100') },
]}
/>
</SettingItem>
{/* External Content */}
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { ReactNode } from 'react';
interface SettingsSectionProps {
title: string;
description?: string;
children: ReactNode;
}
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="space-y-4">{children}</div>
</div>
);
}
interface SettingItemProps {
label: string;
description?: string;
children: ReactNode;
}
export function SettingItem({ label, description, children }: SettingItemProps) {
return (
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
<div className="flex-1 pr-4">
<label className="text-sm font-medium text-foreground">{label}</label>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
${checked ? 'bg-primary' : 'bg-muted'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}
interface RadioGroupProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
return (
<div className="flex gap-2">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`
px-3 py-1.5 text-xs rounded transition-colors
${
value === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-foreground'
}
`}
>
{option.label}
</button>
))}
</div>
);
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ value, onChange, options }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
interface AvatarProps {
name?: string;
email?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const getInitials = () => {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
if (email) {
return email[0].toUpperCase();
}
return "?";
};
const getBackgroundColor = () => {
const str = name || email || "";
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
};
const sizeClasses = {
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
};
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white",
sizeClasses[size],
className
)}
style={{ backgroundColor: getBackgroundColor() }}
title={name || email}
>
{getInitials()}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "ghost" | "outline" | "destructive";
size?: "sm" | "md" | "lg" | "icon";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
{
default:
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow",
ghost: "hover:bg-accent hover:text-accent-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
}[variant],
{
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-11 px-8",
icon: "h-10 w-10",
}[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button };
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { forwardRef, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface ContextMenuProps {
isOpen: boolean;
position: Position;
onClose: () => void;
children: React.ReactNode;
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || !isOpen) return null;
return createPortal(
<div
ref={ref}
className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={{
left: position.x,
top: position.y,
}}
role="menu"
aria-orientation="vertical"
>
<div className="py-1">
{children}
</div>
</div>,
document.body
);
}
);
ContextMenu.displayName = "ContextMenu";
interface ContextMenuItemProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
}
export function ContextMenuItem({
icon: Icon,
label,
onClick,
disabled = false,
destructive = false,
shortcut,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
disabled={disabled}
className={cn(
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
"transition-colors duration-100",
"focus:outline-none focus:bg-muted",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "hover:bg-muted cursor-pointer",
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
)}
onClick={(e) => {
if (disabled) return;
e.stopPropagation();
onClick();
}}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground ml-auto">{shortcut}</span>
)}
</button>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Check if submenu would overflow right edge
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
}
}, [isOpen]);
return (
<div
ref={itemRef}
className="relative"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<div
className={cn(
"w-full px-3 py-2 text-sm flex items-center gap-2",
"transition-colors duration-100 cursor-pointer",
"hover:bg-muted"
)}
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
{isOpen && (
<div
ref={subMenuRef}
className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100",
subMenuPosition === "right" ? "left-full ml-1" : "right-full mr-1"
)}
role="menu"
>
<div className="py-1 max-h-[300px] overflow-y-auto">
{children}
</div>
</div>
)}
</div>
);
}
interface ContextMenuHeaderProps {
children: React.ReactNode;
}
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
return (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"hover:border-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { locales } from '@/i18n/request';
export function LanguageSwitcher({ className }: { className?: string }) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const t = useTranslations('language');
const currentLocale = params.locale as string;
const handleLanguageChange = (newLocale: string) => {
// Get the path without the locale prefix
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
// Navigate to the same page with the new locale
router.push(`/${newLocale}${pathWithoutLocale}`);
};
return (
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
{locales.map((locale) => (
<button
key={locale}
onClick={() => handleLanguageChange(locale)}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
"text-foreground",
currentLocale === locale
? "bg-background shadow-sm font-medium"
: "hover:bg-accent/50"
)}
title={t(locale === 'en' ? 'english' : 'french')}
>
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
<span className="hidden sm:inline">
{locale.toUpperCase()}
</span>
</button>
))}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
onClick?: () => void;
}
interface ToastProps {
toast: Toast;
onClose: (id: string) => void;
}
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
};
const styles = {
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
};
export function ToastItem({ toast, onClose }: ToastProps) {
const Icon = icons[toast.type];
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => {
onClose(toast.id);
}, toast.duration);
return () => clearTimeout(timer);
}
}, [toast, onClose]);
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
styles[toast.type],
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => {
if (toast.onClick) {
toast.onClick();
onClose(toast.id);
}
}}
>
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium">{toast.title}</h4>
{toast.message && (
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
))}
</div>
);
}