This commit is contained in:
2026-02-25 01:34:09 +00:00
parent 6e8ee69311
commit 0226608d8f
8 changed files with 296 additions and 43 deletions
@@ -1,9 +1,12 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Mail } from 'lucide-react';
import { ChevronLeft, ChevronRight, Mail, Paperclip } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailSummary } from 'types';
const LIMIT = 50;
const formatDate = (iso: string) => {
const date = new Date(iso);
const now = new Date();
@@ -17,13 +20,17 @@ const formatDate = (iso: string) => {
export const EmailList = () => {
const client = useClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['email-messages'],
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages'),
queryKey: ['email-messages', page],
queryFn: () =>
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`),
});
const messages = data?.messages ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / LIMIT));
if (isLoading) {
return (
@@ -33,7 +40,7 @@ export const EmailList = () => {
);
}
if (messages.length === 0) {
if (messages.length === 0 && page === 1) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Mail className="h-8 w-8" />
@@ -47,14 +54,35 @@ export const EmailList = () => {
<div className="flex items-center gap-2 border-b px-3 py-2">
<Mail className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Inbox</span>
<span className="text-xs opacity-50">{data?.total ?? 0}</span>
<span className="text-xs opacity-50">{total}</span>
{totalPages > 1 && (
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs opacity-60">
{page}/{totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex flex-col">
<div className="flex flex-1 flex-col divide-y divide-white/10">
{messages.map((msg: EmailSummary) => (
<button
key={msg.id}
onClick={() => setSelectedId(msg.id)}
className={`flex flex-col gap-0.5 border-b px-3 py-2.5 text-left transition-colors cursor-pointer ${
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
@@ -63,7 +91,15 @@ export const EmailList = () => {
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
</div>
<span className="truncate text-sm">{msg.subject}</span>
<span className="truncate text-xs opacity-50">{msg.snippet}</span>
<div className="flex items-center gap-1.5 text-xs">
{!!msg.attachmentCount && (
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
<Paperclip className="h-3 w-3" />
{msg.attachmentCount}
</span>
)}
<span className="truncate opacity-50">{msg.snippet}</span>
</div>
</button>
))}
</div>
@@ -1,10 +1,18 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Mail } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailMessage } from 'types';
type OpenAttachment = {
filePath: string;
fileName: string;
root: string;
};
const HtmlBody = ({ html }: { html: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -36,6 +44,7 @@ const HtmlBody = ({ html }: { html: string }) => {
export const EmailReader = () => {
const client = useClient();
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
const { data: message, isLoading } = useQuery({
queryKey: ['email-message', selectedId],
@@ -43,6 +52,12 @@ export const EmailReader = () => {
enabled: !!selectedId,
});
const extractAttachment = async (index: number) => {
if (!selectedId) return;
const result = await client.post<OpenAttachment>(`/email/messages/${selectedId}/attachments/${index}/extract`);
setOpenAttachment(result);
};
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
@@ -83,9 +98,13 @@ export const EmailReader = () => {
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
<span key={i} className="rounded bg-accent px-2 py-0.5 text-xs">
<button
key={i}
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
onClick={() => extractAttachment(i)}
>
{att.filename} ({Math.round(att.size / 1024)}KB)
</span>
</button>
))}
</div>
)}
@@ -97,6 +116,26 @@ export const EmailReader = () => {
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre>
)}
</div>
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && (
<FileViewerProvider
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
<FileViewerHeader />
</div>
<div className="min-h-0 flex-1">
<FileViewerBody />
</div>
</FileViewerProvider>
)}
</DialogContent>
</Dialog>
</div>
);
};