diff --git a/src/app/(inbox)/calendar/page.tsx b/src/app/(inbox)/calendar/page.tsx index 04de8f1..e0835f6 100644 --- a/src/app/(inbox)/calendar/page.tsx +++ b/src/app/(inbox)/calendar/page.tsx @@ -17,6 +17,7 @@ interface Props { export default async function CalendarPage({ searchParams }: Props) { const { month } = await searchParams; const monthKey = normalizeMonthKey(month); + const currentMonthKey = normalizeMonthKey(undefined); const { session, accountId, mailboxes } = await getJmapMailboxContext(); @@ -60,14 +61,24 @@ export default async function CalendarPage({ searchParams }: Props) { Calendar

- Meetings parsed from recent calendar invite emails. + {monthEntries.length === 0 + ? "No meetings found in invite emails this month." + : `${monthEntries.length} meeting${monthEntries.length === 1 ? "" : "s"} from calendar invite emails.`}

+ {monthKey !== currentMonthKey && ( + + Today + + )} Prev @@ -76,7 +87,7 @@ export default async function CalendarPage({ searchParams }: Props) {
Next @@ -85,12 +96,7 @@ export default async function CalendarPage({ searchParams }: Props) { - {monthEntries.length === 0 ? ( -
- No meetings found for {monthTitle(monthKey)}. -
- ) : ( -
+
{DAY_LABELS.map((label) => (
- )}
); diff --git a/src/app/(inbox)/email/[id]/error.tsx b/src/app/(inbox)/email/[id]/error.tsx index 53a2efe..43eb22a 100644 --- a/src/app/(inbox)/email/[id]/error.tsx +++ b/src/app/(inbox)/email/[id]/error.tsx @@ -1,6 +1,10 @@ "use client"; -export default function EmailError() { +export default function EmailError({ + unstable_retry, +}: { + unstable_retry: () => void; +}) { return (
@@ -9,8 +13,15 @@ export default function EmailError() { Unable to load this email

- There was a problem fetching this email from the server. Refresh to try again. + There was a problem fetching this email from the server.

+
diff --git a/src/app/(inbox)/page.tsx b/src/app/(inbox)/page.tsx index 8c860ee..9d19281 100644 --- a/src/app/(inbox)/page.tsx +++ b/src/app/(inbox)/page.tsx @@ -1,9 +1,13 @@ +import EmptyState from "@/components/EmptyState"; + export default function InboxPage() { return ( -
-

- Select an email to read -

+
+
); } diff --git a/src/app/(inbox)/thread/[threadId]/error.tsx b/src/app/(inbox)/thread/[threadId]/error.tsx index f6725aa..f10e65d 100644 --- a/src/app/(inbox)/thread/[threadId]/error.tsx +++ b/src/app/(inbox)/thread/[threadId]/error.tsx @@ -1,6 +1,10 @@ "use client"; -export default function ThreadError() { +export default function ThreadError({ + unstable_retry, +}: { + unstable_retry: () => void; +}) { return (
@@ -9,8 +13,15 @@ export default function ThreadError() { Unable to load this thread

- There was a problem fetching this email from the server. Refresh to try again. + There was a problem fetching this thread from the server.

+
diff --git a/src/app/compose/page.tsx b/src/app/compose/page.tsx index c10aee9..299fef9 100644 --- a/src/app/compose/page.tsx +++ b/src/app/compose/page.tsx @@ -164,8 +164,8 @@ export default async function ComposePage({ searchParams }: Props) { return (
-
- +
+

{title}

diff --git a/src/app/globals.css b/src/app/globals.css index ec732a3..9700089 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -62,11 +62,29 @@ select { -webkit-tap-highlight-color: transparent; } +button, +summary { + cursor: pointer; +} + +button:disabled { + cursor: default; +} + input, textarea { caret-color: #2563eb; } +input[type="search"]::-webkit-search-cancel-button { + display: none; +} + +::selection { + background: #bfdbfe; + color: #0f172a; +} + :focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c634d90..50abe37 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { unstable_rethrow } from "next/navigation"; import { Suspense } from "react"; import { auth, signOut } from "@/auth"; -import LiveUnreadCountBadge from "@/components/LiveUnreadCountBadge"; +import DesktopNav from "@/components/DesktopNav"; import MobileNav from "@/components/MobileNav"; +import ToastProvider from "@/components/ToastProvider"; import UnreadCountProvider, { MailboxCountSync, } from "@/components/UnreadCountProvider"; @@ -54,64 +54,33 @@ export default async function RootLayout({ - {session && ( - - - - )} - {/* Row: desktop sidebar + main content */} -
- {/* Sidebar — desktop only */} - - {/* Main content */} -
{children}
-
+ {/* Main content */} +
{children}
+
- {/* Mobile bottom nav — hidden on desktop */} -
+ {/* Mobile bottom nav — hidden on desktop */} +
+ diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index b4c0d80..57f8666 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -4,12 +4,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { marked } from "marked"; import { saveDraftAction, deleteDraftAction } from "@/app/compose/actions"; +import { useToast } from "@/components/ToastProvider"; import { markQuotedReplyHtml, wrapComposePreviewHtml, wrapEmailHtml, } from "@/lib/composeHtml"; -import { normalizeComposeMarkdown, htmlToPlainText } from "@/lib/compose"; +import { + normalizeComposeMarkdown, + htmlToPlainText, + quotedSectionStart, +} from "@/lib/compose"; // --------------------------------------------------------------------------- // RecipientInput — text input with contact autocomplete dropdown @@ -223,6 +228,7 @@ export default function Composer({ forwardedHtml, }: Props) { const router = useRouter(); + const showToast = useToast(); const [identityId, setIdentityId] = useState(identities[0]?.id ?? ""); const [to, setTo] = useState(initialTo); const [cc, setCc] = useState(initialCc); @@ -233,9 +239,10 @@ export default function Composer({ const [markdown, setMarkdown] = useState(initialBody); const [preview, setPreview] = useState(""); const [showPreview, setShowPreview] = useState(false); + const [showQuotedHistory, setShowQuotedHistory] = useState(false); + const [dragActive, setDragActive] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(null); - const [sent, setSent] = useState(false); const [inlineImages, setInlineImages] = useState([]); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(0); @@ -256,6 +263,12 @@ export default function Composer({ const fileInputRef = useRef(null); const inlineImagesRef = useRef(inlineImages); const attachmentsRef = useRef(attachments); + const quoteStart = quotedSectionStart(markdown); + const hasQuotedHistory = quoteStart >= 0; + const editorMarkdown = + hasQuotedHistory && !showQuotedHistory + ? markdown.slice(0, quoteStart) + : markdown; useEffect(() => { inlineImagesRef.current = inlineImages; }, [inlineImages]); @@ -450,6 +463,14 @@ export default function Composer({ }, []); const handleDiscard = useCallback(async () => { + const hasContent = + !!to.trim() || + !!cc.trim() || + !!bcc.trim() || + !!subject.trim() || + !!markdown.trim() || + attachmentsRef.current.length > 0; + if (hasContent && !window.confirm("Discard this draft?")) return; suppressDraftSideEffectsRef.current = true; cleanupPendingDraftsRef.current = true; if (!draftIdRef.current) { @@ -465,7 +486,7 @@ export default function Composer({ cleanupSuppressedDrafts(); window.history.replaceState({}, "", "/compose"); window.history.back(); - }, [cleanupSuppressedDrafts]); + }, [bcc, cc, cleanupSuppressedDrafts, markdown, subject, to]); const handleSend = useCallback(async () => { if (!to.trim() || !subject.trim() || !markdown.trim()) { @@ -544,9 +565,11 @@ export default function Composer({ (typeof sendResult.threadId === "string" ? sendResult.threadId : undefined); if (destinationThreadId) { + showToast({ message: "Message sent" }); router.replace(`/thread/${destinationThreadId}`); } else { - setSent(true); + showToast({ message: "Message sent" }); + router.replace("/sent"); } } catch (e) { const pendingDraftIds = pendingSuppressedDraftIdsRef.current; @@ -577,6 +600,7 @@ export default function Composer({ inReplyToId, replyThreadId, router, + showToast, cleanupSuppressedDrafts, ]); @@ -588,34 +612,40 @@ export default function Composer({ handleSend(); }, [handleSend]); - if (sent) { - return ( -
- Message sent.{" "} - -
+ function updateEditorMarkdown(next: string) { + if (!hasQuotedHistory || showQuotedHistory) { + setMarkdown(next); + return; + } + setMarkdown( + `${next.trimEnd()}\n\n${markdown.slice(quoteStart).trimStart()}`, ); } + function insertMarkdown( + before: string, + after: string, + placeholder: string, + ) { + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selected = editorMarkdown.slice(start, end) || placeholder; + const next = + editorMarkdown.slice(0, start) + + before + + selected + + after + + editorMarkdown.slice(end); + updateEditorMarkdown(next); + requestAnimationFrame(() => { + textarea.focus(); + const selectionStart = start + before.length; + textarea.setSelectionRange(selectionStart, selectionStart + selected.length); + }); + } + const fieldClass = "flex-1 text-sm text-stone-700 dark:text-stone-300 bg-transparent outline-none placeholder:text-stone-300 dark:placeholder:text-stone-600"; const labelClass = "text-xs text-stone-400 dark:text-stone-500 w-16"; @@ -704,22 +734,80 @@ export default function Composer({
{/* Toolbar */} -
- Markdown +
+ + Markdown + +
+ + + + +
+ {hasQuotedHistory && ( + + )} {uploading > 0 && ( - - Uploading {uploading} image{uploading > 1 ? "s" : ""}… + + Uploading {uploading} file{uploading > 1 ? "s" : ""}… )} {/* Save status */} - + {saveStatus === "saving" && "Saving…"} {saveStatus === "saved" && lastSaved && `Saved ${lastSaved.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`} {saveStatus === "error" && "Draft save failed"} -
+
{/* Editor area */} -
+
{ + if (event.dataTransfer.types.includes("Files")) { + event.preventDefault(); + setDragActive(true); + } + }} + onDragOver={(event) => { + if (event.dataTransfer.types.includes("Files")) event.preventDefault(); + }} + onDragLeave={(event) => { + if (event.currentTarget === event.target) setDragActive(false); + }} + onDrop={(event) => { + if (event.dataTransfer.files.length === 0) return; + event.preventDefault(); + setDragActive(false); + void handleAttach(event.dataTransfer.files); + }} + > + {dragActive && ( +
+ Drop files to attach +
+ )}