From 8c2943dd5fdff6639946d191bbf15e8a758c2cf1 Mon Sep 17 00:00:00 2001 From: quinn Date: Fri, 15 May 2026 18:20:11 -0700 Subject: [PATCH] merge batch 6: web App.tsx routes, AppShell nav, api/client apiDelete, api/mail sendMail, Mail tab compose+deep-link --- web/src/App.tsx | 12 +++ web/src/api/client.ts | 20 ++++ web/src/api/mail.ts | 32 +++++- web/src/layout/AppShell.tsx | 15 ++- web/src/tabs/Mail/index.tsx | 208 ++++++++++++++++++++++++++++++++++-- 5 files changed, 274 insertions(+), 13 deletions(-) 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 +