diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2c31c06..b1a126b 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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 {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index a85a1d9..6db1476 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -96,3 +96,23 @@ export async function apiPost(path: string, body?: unknown): Promise {
throw new ApiError(0, path);
}
}
+
+export async function apiDelete(path: string): Promise {
+ 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;
+ } catch (err) {
+ if (err instanceof ApiError) throw err;
+ throw new ApiError(0, path);
+ }
+}
diff --git a/web/src/api/mail.ts b/web/src/api/mail.ts
index 1447114..b3f520a 100644
--- a/web/src/api/mail.ts
+++ b/web/src/api/mail.ts
@@ -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 {
@@ -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 {
+ readonly success: boolean;
+ readonly data: T;
+}
+
+export async function sendMail(input: SendMailInput, deviceId: string): Promise {
+ try {
+ const res = await apiPost>(
+ `/my/mail/send?deviceId=${encodeURIComponent(deviceId)}`,
+ input,
+ );
+ return res.data;
+ } catch (err) {
+ throw err instanceof Error ? err : new Error('sendMail failed');
+ }
+}
diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx
index 7ed0061..1c98f69 100644
--- a/web/src/layout/AppShell.tsx
+++ b/web/src/layout/AppShell.tsx
@@ -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: },
{ to: '/photos', label: 'Photos', end: false, icon: },
{ to: '/mail', label: 'Mail', end: false, icon: },
+ { to: '/calendar', label: 'Calendar', end: false, icon: },
+ { to: '/reminders', label: 'Reminders', end: false, icon: },
+ { to: '/notes', label: 'Notes', end: false, icon: },
{ to: '/settings', label: 'Settings', end: false, icon: },
];
diff --git a/web/src/tabs/Mail/index.tsx b/web/src/tabs/Mail/index.tsx
index baded02..45d85a1 100644
--- a/web/src/tabs/Mail/index.tsx
+++ b/web/src/tabs/Mail/index.tsx
@@ -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(convParam);
- const { data, isLoading, error } = useMailConversations();
+ const [composing, setComposing] = useState(false);
+ const [form, setForm] = useState(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): 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 (
- Mail
+
+ Mail
+
+
+ setComposing(true)}
+ disabled={composing || sendMut.isPending}
+ >
+ Compose
+
+
+
+ {composing && (
+
+
+ To
+ setForm({ ...form, to: e.target.value })}
+ placeholder="alice@example.com"
+ disabled={sendMut.isPending}
+ />
+
+
+ Subject
+ setForm({ ...form, subject: e.target.value })}
+ disabled={sendMut.isPending}
+ />
+
+
+ Body
+
+
+
+ {sendMut.isPending ? 'Queuing…' : 'Send'}
+
+
+ Cancel
+
+ {!DEVICE_ID && (
+
+ Set DEVICE_ID before sending (no SSO device resolution yet).
+
+ )}
+ {sendMut.isError && (
+
+ {sendMut.error instanceof Error ? sendMut.error.message : 'Send failed'}
+
+ )}
+ {sendMut.isSuccess && Queued for delivery.}
+
+
+ )}
+
{isLoading && Loading…}
{error && }
- {!isLoading && !error && threads.length === 0 && (
+ {!isLoading && !error && data?.length === 0 && (
} heading="No emails" sub="Email threads will appear here once sync runs." />
)}
- {!isLoading && !error && threads.length > 0 && (
+ {!isLoading && !error && data && data.length > 0 && (
<>
- {threads.length} thread{threads.length !== 1 ? 's' : ''}
-
+ {data.length} thread{data.length !== 1 ? 's' : ''}
+
>
)}