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:
parent
4ba759ba92
commit
8c2943dd5f
5 changed files with 274 additions and 13 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} /> },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue