295 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|