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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user