macsync/web/src/tabs/Mail/index.tsx

295 lines
7.8 KiB
TypeScript

import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import { Mail, Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from '@lilith/ui-router';
import { useMailConversations } from '@/hooks/useMail';
import { sendMail, type SendMailInput } from '@/api/mail';
import { MailList } from './MailList';
import { MailThread } from './MailThread';
import { EmptyState } from '@/components/EmptyState';
import { PageError } from '@/components/PageError';
// Server-side `/my/mail/send` requires a deviceId. Once SSO-driven device
// resolution lands on the web side this will source from the session.
const DEVICE_ID: string | undefined = undefined;
const Root = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const PageHeading = styled.h1`
margin: 0;
padding: 14px 20px;
font-size: 18px;
font-weight: 700;
color: #e0e0f0;
border-bottom: 1px solid #2a2a38;
background: #0a0a0f;
display: flex;
align-items: center;
justify-content: space-between;
`;
const Toolbar = styled.div`
padding: 8px 20px;
border-bottom: 1px solid #2a2a38;
background: #0a0a0f;
`;
const PrimaryButton = styled.button`
background: #4a4af0;
border: none;
color: white;
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const Body = styled.div`
display: flex;
flex: 1;
overflow: hidden;
`;
const SidePanel = styled.aside`
width: 280px;
flex-shrink: 0;
border-right: 1px solid #2a2a38;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d0d14;
@media (max-width: 640px) {
width: 100%;
}
`;
const Content = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d0d14;
`;
const ThreadCount = styled.span`
font-size: 11px;
color: #8a8a9a;
padding: 8px 16px 4px;
`;
const SpinnerWrap = styled.div`
padding: 20px;
font-size: 12px;
color: #8a8a9a;
text-align: center;
`;
const FormCard = styled.form`
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid #2a2a38;
background: #0d0d14;
`;
const Field = styled.label`
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: #a0a0b0;
`;
const TextInput = styled.input`
background: #18182a;
border: 1px solid #2a2a38;
color: #e0e0f0;
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
`;
const TextArea = styled.textarea`
background: #18182a;
border: 1px solid #2a2a38;
color: #e0e0f0;
border-radius: 6px;
padding: 8px;
font-size: 13px;
font-family: inherit;
min-height: 120px;
resize: vertical;
`;
const ButtonRow = styled.div`
display: flex;
gap: 8px;
align-items: center;
`;
const Ghost = styled.button`
background: transparent;
color: #a0a0b0;
border: 1px solid #2a2a38;
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
`;
const StatusLine = styled.span<{ $variant: 'info' | 'error' | 'success' }>`
font-size: 11px;
color: ${(p) =>
p.$variant === 'success' ? '#7ad97a' : p.$variant === 'error' ? '#e07a7a' : '#8a8a9a'};
`;
interface ComposeState {
to: string;
subject: string;
body: string;
}
const blankForm = (): ComposeState => ({ to: '', subject: '', body: '' });
export function MailTab(): ReactElement {
const [searchParams] = useSearchParams();
const convParam = searchParams.get('conv');
const [selectedId, setSelectedId] = useState<string | null>(convParam);
const [composing, setComposing] = useState(false);
const [form, setForm] = useState<ComposeState>(blankForm);
useEffect(() => {
if (convParam) setSelectedId(convParam);
}, [convParam]);
const { data, isLoading, error } = useMailConversations();
const qc = useQueryClient();
const selected = data?.find((c) => c.id === selectedId) ?? null;
const sendMut = useMutation({
mutationFn: (input: SendMailInput) => sendMail(input, DEVICE_ID ?? ''),
onSuccess: () => {
setForm(blankForm());
setComposing(false);
qc.invalidateQueries({ queryKey: ['mail-conversations'] });
},
});
const onSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!form.to || !form.subject || !form.body) return;
sendMut.mutate({ to: form.to, subject: form.subject, body: form.body });
};
const cancel = (): void => {
setForm(blankForm());
setComposing(false);
sendMut.reset();
};
return (
<Root>
<PageHeading>
<span>Mail</span>
</PageHeading>
<Toolbar>
<PrimaryButton
type="button"
onClick={() => setComposing(true)}
disabled={composing || sendMut.isPending}
>
<Plus size={13} /> Compose
</PrimaryButton>
</Toolbar>
{composing && (
<FormCard onSubmit={onSubmit}>
<Field>
<span>To</span>
<TextInput
type="email"
required
value={form.to}
onChange={(e) => setForm({ ...form, to: e.target.value })}
placeholder="alice@example.com"
disabled={sendMut.isPending}
/>
</Field>
<Field>
<span>Subject</span>
<TextInput
type="text"
required
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
disabled={sendMut.isPending}
/>
</Field>
<Field>
<span>Body</span>
<TextArea
required
value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })}
disabled={sendMut.isPending}
/>
</Field>
<ButtonRow>
<PrimaryButton type="submit" disabled={sendMut.isPending || !DEVICE_ID}>
{sendMut.isPending ? 'Queuing…' : 'Send'}
</PrimaryButton>
<Ghost type="button" onClick={cancel} disabled={sendMut.isPending}>
Cancel
</Ghost>
{!DEVICE_ID && (
<StatusLine $variant="info">
Set DEVICE_ID before sending (no SSO device resolution yet).
</StatusLine>
)}
{sendMut.isError && (
<StatusLine $variant="error">
{sendMut.error instanceof Error ? sendMut.error.message : 'Send failed'}
</StatusLine>
)}
{sendMut.isSuccess && <StatusLine $variant="success">Queued for delivery.</StatusLine>}
</ButtonRow>
</FormCard>
)}
<Body>
<SidePanel aria-label="Email thread list">
{isLoading && <SpinnerWrap aria-live="polite">Loading</SpinnerWrap>}
{error && <PageError message="Failed to load email threads" />}
{!isLoading && !error && data?.length === 0 && (
<EmptyState icon={<Mail size={28} />} heading="No emails" sub="Email threads will appear here once sync runs." />
)}
{!isLoading && !error && data && data.length > 0 && (
<>
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
</>
)}
</SidePanel>
<Content>
{selected ? (
<MailThread conversationId={selected.id} subject={selected.displayName || '(no subject)'} />
) : (
<EmptyState icon={<Mail size={36} />} heading="Select a thread" sub="Choose an email thread from the left panel." />
)}
</Content>
</Body>
</Root>
);
}