merge batch 6: web App.tsx routes, AppShell nav, api/client apiDelete, api/mail sendMail, Mail tab compose+deep-link

This commit is contained in:
quinn 2026-05-15 18:20:11 -07:00
parent 4ba759ba92
commit 8c2943dd5f
5 changed files with 274 additions and 13 deletions

View file

@ -24,6 +24,15 @@ const SettingsTab = lazy(() =>
const ContactsTab = lazy(() =>
import('./tabs/Contacts').then((m) => ({ default: m.ContactsTab })),
);
const CalendarTab = lazy(() =>
import('./tabs/Calendar').then((m) => ({ default: m.CalendarTab })),
);
const RemindersTab = lazy(() =>
import('./tabs/Reminders').then((m) => ({ default: m.RemindersTab })),
);
const NotesTab = lazy(() =>
import('./tabs/Notes').then((m) => ({ default: m.NotesTab })),
);
const queryClient = new QueryClient({
defaultOptions: {
@ -68,6 +77,9 @@ export function App(): ReactElement {
<Route path="/messages" element={<MessagesTab />} />
<Route path="/photos" element={<PhotosTab />} />
<Route path="/mail" element={<MailTab />} />
<Route path="/calendar" element={<CalendarTab />} />
<Route path="/reminders" element={<RemindersTab />} />
<Route path="/notes" element={<NotesTab />} />
<Route path="/contacts" element={<ContactsTab />} />
<Route path="/settings" element={<SettingsTab />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />

View file

@ -96,3 +96,23 @@ export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
throw new ApiError(0, path);
}
}
export async function apiDelete<T>(path: string): Promise<T> {
try {
const base = getBaseUrl(path);
const headers: HeadersInit = {};
if (serviceToken && isUpstreamPath(path)) {
headers['Authorization'] = `Bearer ${serviceToken}`;
}
const res = await fetch(`${base}${path}`, {
method: 'DELETE',
credentials: 'include',
headers,
});
if (!res.ok) throw new ApiError(res.status, path);
return res.json() as Promise<T>;
} catch (err) {
if (err instanceof ApiError) throw err;
throw new ApiError(0, path);
}
}

View file

@ -1,4 +1,4 @@
import { apiGet } from './client';
import { apiGet, apiPost } from './client';
import type { Conversation, Message } from '@/types';
export async function fetchMailConversations(deviceId?: string): Promise<Conversation[]> {
@ -19,3 +19,33 @@ export async function fetchMailThread(conversationId: string, limit = 100, since
throw err instanceof Error ? err : new Error('fetchMailThread failed');
}
}
export interface SendMailInput {
readonly to: string;
readonly cc?: readonly string[];
readonly bcc?: readonly string[];
readonly subject: string;
readonly body: string;
readonly isHtml?: boolean;
}
export interface SendMailResult {
readonly sendQueueId: string;
}
interface Envelope<T> {
readonly success: boolean;
readonly data: T;
}
export async function sendMail(input: SendMailInput, deviceId: string): Promise<SendMailResult> {
try {
const res = await apiPost<Envelope<SendMailResult>>(
`/my/mail/send?deviceId=${encodeURIComponent(deviceId)}`,
input,
);
return res.data;
} catch (err) {
throw err instanceof Error ? err : new Error('sendMail failed');
}
}

View file

@ -1,7 +1,17 @@
import { type ReactElement, type ReactNode } from 'react';
import { NavLink } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { BarChart3, MessageSquare, Image, Mail, Settings, Users } from 'lucide-react';
import {
BarChart3,
Calendar,
CheckSquare,
FileText,
Image,
Mail,
MessageSquare,
Settings,
Users,
} from 'lucide-react';
import { GlobalSearch } from '@/components/GlobalSearch';
const TOKENS = {
@ -21,6 +31,9 @@ const NAV_ITEMS: { to: string; label: string; end: boolean; icon: ReactNode }[]
{ to: '/contacts', label: 'Contacts', end: false, icon: <Users size={16} /> },
{ to: '/photos', label: 'Photos', end: false, icon: <Image size={16} /> },
{ to: '/mail', label: 'Mail', end: false, icon: <Mail size={16} /> },
{ to: '/calendar', label: 'Calendar', end: false, icon: <Calendar size={16} /> },
{ to: '/reminders', label: 'Reminders', end: false, icon: <CheckSquare size={16} /> },
{ to: '/notes', label: 'Notes', end: false, icon: <FileText size={16} /> },
{ to: '/settings', label: 'Settings', end: false, icon: <Settings size={16} /> },
];

View file

@ -1,13 +1,19 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import { Mail } from 'lucide-react';
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;
@ -22,6 +28,30 @@ const PageHeading = styled.h1`
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`
@ -65,34 +95,190 @@ const SpinnerWrap = styled.div`
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 { data, isLoading, error } = useMailConversations();
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 threads = Array.isArray(data) ? data : [];
const selected = threads.find((c) => c.id === selectedId) ?? null;
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>Mail</PageHeading>
<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 && threads.length === 0 && (
{!isLoading && !error && data?.length === 0 && (
<EmptyState icon={<Mail size={28} />} heading="No emails" sub="Email threads will appear here once sync runs." />
)}
{!isLoading && !error && threads.length > 0 && (
{!isLoading && !error && data && data.length > 0 && (
<>
<ThreadCount>{threads.length} thread{threads.length !== 1 ? 's' : ''}</ThreadCount>
<MailList conversations={threads} selectedId={selectedId} onSelect={setSelectedId} />
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
</>
)}
</SidePanel>