diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index b08709a6..70990fa8 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -1,17 +1,15 @@ -import HeadSelectMenu from "@/components/app/head/HeadSelectMenu"; -import SelectOption from "@/components/app/popup/select/SelectOption"; -import Search from "@/components/app/Search"; import { useUserProfile } from "@/hooks/context/user"; import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; -import type Folder from "@/lib/api/models/app/Folder"; -import { RiFolderLine, RiSoundModuleLine } from "@remixicon/react"; -import React, { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { CalendarIcon, FilterIcon, - MegaphoneIcon, + FolderIcon, + PauseIcon, + PlayIcon, PlusIcon, + Settings2Icon, } from "lucide-react"; import { EmptyBlock, @@ -19,29 +17,51 @@ import { PageBody, PageTopbar, SectionBar, + Stat, + StatStrip, TopbarAction, } from "@/components/layout/Page"; +import { SearchInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuSeparator, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; -const DefaultFolder = { - title: "All folders", - color: "#c4c8cf", -} as Folder; +type StatusFilter = "all" | "active" | "paused" | "draft"; export default function CampaignsPage() { - const [folder, setFolder] = React.useState(""); - const [query, setQuery] = React.useState(""); - const campaignsData = useCampaigns({ query, folder }); const p = useUserProfile(); + const [folder, setFolder] = useState(""); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("all"); - const sfolder = useMemo(() => { - if (!p) return DefaultFolder; - const f = p.user.folders.find((f) => f.id === folder); - if (!f) return DefaultFolder; - return f; - }, [folder, p]); - + const campaignsData = useCampaigns({ query, folder }); const campaigns = campaignsData.campaigns ?? []; + const folders = p.user.folders ?? []; + const activeFolder = folders.find((f) => f.id === folder); + + const filtered = useMemo( + () => (status === "all" ? campaigns : campaigns.filter((c) => (c.status ?? "draft") === status)), + [campaigns, status], + ); + + const counts = useMemo(() => { + const stats = { total: campaigns.length, active: 0, paused: 0, draft: 0 }; + for (const c of campaigns) { + const s = c.status ?? "draft"; + if (s === "active") stats.active++; + else if (s === "paused") stats.paused++; + else stats.draft++; + } + return stats; + }, [campaigns]); + return ( { - /* TODO: open new-campaign modal */ - }} - icon={} + variant="ghost" + icon={} > + Folders + + }> New campaign - - setQuery(v)} /> - } title={sfolder.title}> - {p?.user.folders.map((fo) => ( - (folder !== fo.id ? setFolder(fo.id) : setFolder(""))} - color={fo.color} - selected={folder === fo.id} + + setStatus("all")} + /> + 0} + onClick={() => setStatus("active")} + /> + setStatus("paused")} + /> + setStatus("draft")} + /> + + + + + + + + } + label={activeFolder?.title ?? "All folders"} + /> + + + Folders + setFolder("")} + selected={!folder} > - - {fo.title} - - ))} - p?.setFoldersEdit(true)}> - - Manage folders - - + All folders + + {folders.map((f) => ( + setFolder(folder === f.id ? "" : f.id)} + icon={} + selected={folder === f.id} + > + {f.title} + + ))} + + p.setFoldersEdit(true)} + icon={} + > + Manage folders + + + + + + + } + label="More" + /> + + + Sort + Newest first + Oldest first + Name (A–Z) + + {campaignsData.isPending ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
- ))} -
+ ) : campaignsData.isError ? ( campaignsData.refetch()} variant="ghost"> @@ -109,26 +192,42 @@ export default function CampaignsPage() { } /> - ) : campaigns.length === 0 ? ( - }> - New campaign - - } - /> + ) : filtered.length === 0 ? ( + campaigns.length === 0 ? ( + }> + New campaign + + } + /> + ) : ( + setStatus("all")} variant="ghost"> + Show all + + } + /> + ) ) : (
- {campaigns.map((c) => { - const status = c.status || "draft"; + {filtered.map((c) => { + const cstatus = c.status ?? "draft"; const dot = - status === "active" + cstatus === "active" ? "bg-emerald-500" - : status === "draft" - ? "bg-slate-300" - : "bg-amber-500"; + : cstatus === "paused" + ? "bg-amber-500" + : "bg-slate-300"; + const stateLabel = + cstatus === "active" ? "running" : cstatus; + const StateIcon = + cstatus === "active" ? PauseIcon : PlayIcon; return ( - - + {c.name} - + {c.id.slice(0, 8)} {c.description && ( @@ -148,18 +246,32 @@ export default function CampaignsPage() { {c.description} )} - - {status} + + {stateLabel} - + {c.created_at ? new Date(c.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", }) - : "--"} + : "—"} + ); })} @@ -169,3 +281,18 @@ export default function CampaignsPage() { ); } + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/web/src/app/app/unibox/page.tsx b/web/src/app/app/unibox/page.tsx index 75b7f2dc..d93a31d3 100644 --- a/web/src/app/app/unibox/page.tsx +++ b/web/src/app/app/unibox/page.tsx @@ -1,31 +1,42 @@ -import { ConversationList } from '@/components/app/unibox/ConversationList' -import { ThreadView } from '@/components/app/unibox/ThreadView' -import { useAppStore } from '@/stores' -import { InboxIcon } from 'lucide-react' +// Unibox page — three-pane mail browser. +// +// ┌────────────────┬──────────────────────────────────────┐ +// │ ConversationList │ ThreadView │ +// │ (340px) │ (fills remainder) │ +// └────────────────┴──────────────────────────────────────┘ + +import { ConversationList } from "@/components/app/unibox/ConversationList"; +import { ThreadView } from "@/components/app/unibox/ThreadView"; +import { useAppStore } from "@/stores"; +import { InboxIcon } from "lucide-react"; export default function UniboxPage() { - const selectedThreadId = useAppStore((s) => s.selectedThreadId) + const selectedThreadId = useAppStore((s) => s.selectedThreadId); - return ( -
-
- -
-
- {selectedThreadId ? ( - - ) : ( -
-
-
- -
-

Select a conversation

-

Choose a thread from the left to view messages

+ return ( +
+
+
-
- )} -
-
- ) +
+ {selectedThreadId ? ( + + ) : ( +
+
+
+ +
+

+ Select a conversation +

+

+ Pick a thread from the list to read and reply. +

+
+
+ )} +
+
+ ); } diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 75651e9f..10de4834 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -1,240 +1,675 @@ -import { RiAddLine, RiEqualizer3Line, RiTeamLine } from "@remixicon/react"; +// Contacts browser — brae-density rewrite. +// +// Visible chrome: PageTopbar > StatStrip > SectionBar > scroll body. +// Body is a dense table where each row is h-11, hairline divider, hover +// row reveals quick actions. Selecting rows pops a footer action bar. +// +// Works in two contexts: +// - /app/contacts → full standalone browser. +// - /app/campaigns/[id]/leads → scoped to a single campaign; the +// parent passes `current_campaign` and the topbar collapses to a +// section header so it nests cleanly under the campaign view. + import React from "react"; -import { Loading } from "../../loader"; +import { + Building2Icon, + CheckIcon, + DownloadIcon, + Loader2Icon, + MailIcon, + MoreHorizontalIcon, + PhoneIcon, + PlusIcon, + Settings2Icon, + TrashIcon, + UploadIcon, + UserPlusIcon, +} from "lucide-react"; + import { useConfirm } from "@/hooks/context/confirm"; -import Checkbox from "../Checkbox"; -import { twColors } from "tailwindv4-colors"; import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts"; import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; -import HeadSearch from "../head/HeadSearch"; -import HeadMenu from "../head/HeadMenu"; -import HeadButton from "../head/HeadButton"; import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import ContactFilters from "./ContactFilters"; -import ContactEntry from "./ContactEntry"; import ContactEdit from "./ContactEdit"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; import ContactsEditBulk from "./ContactsEditBulk"; +import { + EmptyBlock, + Page, + PageBody, + PageTopbar, + SectionBar, + Stat, + StatStrip, + TopbarAction, +} from "@/components/layout/Page"; +import { SearchInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuSeparator, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; + +type SubFilter = "all" | "subscribed" | "unsubscribed"; export default function ContactsTable({ current_campaign, }: { - current_campaign?: MiniCampaign, + current_campaign?: MiniCampaign; }) { const confirm = useConfirm(); const [selected, setSelected] = React.useState([]); const [del, setDelete] = React.useState(false); - const [filters, setFilters] = React.useState(false); + const [filtersOpen, setFiltersOpen] = React.useState(false); const [edit, setEdit] = React.useState(""); const [bulkEdit, setBulkEdit] = React.useState(false); + const [subFilter, setSubFilter] = React.useState("all"); const [searchProps, setSearchProps] = React.useState({ query: "", filters: [], campaign_ids: current_campaign ? [current_campaign.id] : [], - sort_by: 'created_at', + sort_by: "created_at", reverse: false, - }) - const contactsData = useSearchContacts({ - options: searchProps, }); + const contactsData = useSearchContacts({ options: searchProps }); const contactsBulkDelete = useDeleteContacts(); + const contacts = contactsData.contacts; + const total = contactsData.data?.pages[0]?.pagination.total ?? 0; + const filtered = React.useMemo(() => { + if (!contacts) return []; + if (subFilter === "all") return contacts; + return contacts.filter((c) => + subFilter === "subscribed" ? c.subscribed : !c.subscribed, + ); + }, [contacts, subFilter]); + + const counts = React.useMemo(() => { + const stats = { total: contacts?.length ?? 0, subscribed: 0, unsubscribed: 0, inCampaign: 0 }; + for (const c of contacts ?? []) { + if (c.subscribed) stats.subscribed++; + else stats.unsubscribed++; + if (c.campaigns && c.campaigns.length > 0) stats.inCampaign++; + } + return stats; + }, [contacts]); const isSelectedAll = React.useMemo(() => { - if (!contactsData) return false; - return !contactsData.contacts?.some((v) => !selected.some((s) => s === v.id)) - }, [selected, contactsData]) + if (!filtered.length) return false; + return filtered.every((v) => selected.includes(v.id)); + }, [filtered, selected]); - async function BulkDelete() { + function toggleAll() { + if (isSelectedAll) { + setSelected((bef) => bef.filter((id) => !filtered.some((c) => c.id === id))); + } else { + setSelected((bef) => Array.from(new Set([...bef, ...filtered.map((c) => c.id)]))); + } + } + + async function bulkDelete() { if (selected.length === 0) return; try { confirm?.setLoading(true); - const cselected = selected; + const ids = selected; try { setDelete(true); - await toast.promise( - contactsBulkDelete.mutateAsync(cselected), - { - loading: `Deleting ${cselected.length} contacts...`, - success: "Contacts successfully deleted.", - error: (err: AppError) => buildError(err), - } - ) + await toast.promise(contactsBulkDelete.mutateAsync(ids), { + loading: `Deleting ${ids.length} contacts…`, + success: "Contacts deleted", + error: (err: AppError) => buildError(err), + }); + setSelected([]); } finally { setDelete(false); } } finally { - confirm?.setLoading(false) - confirm?.setShow(false) + confirm?.setLoading(false); + confirm?.setShow(false); } } - return (<> -
-
- { - e.preventDefault(); - setSearchProps(props => ({ - ...props, - search, - })) - }} - /> - - { }}> - - Filters - - { }}> - -
New Contact
-
-
-
- {!contactsData.contacts ? ( -
-
-
-
-
-
- ) : ( - <> - {contactsData.contacts?.length === 0 ? ( -
- -

It looks empty here!

-

Start building your contact list by adding or uploading new contacts. Add your contacts to get started.

-
- ) : (<> -

Showing {contactsData.contacts.length} of {contactsData.data?.pages[0]?.pagination.total ?? 0}{selected.length > 0 && <> - {` (${selected.length} selected)`} - - - }

-
- - - - - - - - - - - - - - - - - {contactsData.contacts.map((contact, index) => { - const isSelected = selected.some((s) => s === contact.id) - return ( - { - if (isSelected) { - setSelected(bef => bef.filter((s) => s !== contact.id)) - } else { - setSelected(bef => [...bef, contact.id]) - } - }} - onEdit={() => { }} - /> - ) - })} - -
-
- { - if (isSelectedAll) { - setSelected([]) - } else { - if (!contactsData.contacts) { - setSelected([]) - } else { - setSelected(contactsData.contacts.map((c) => c.id)) - } - } - }} - /> - -
-
- First Name - - Last Name - - Email - - Company - - Phone - - Subscribed - - Campaigns - - Custom Fields - - Action -
-
- {contactsData.hasNextPage && -
- -
} - )} - - )} -
- - - + setSelected((bef) => (on ? [...bef, id] : bef.filter((x) => x !== id))) + } + isSelectedAll={isSelectedAll} + onToggleAll={toggleAll} + onRowClick={setEdit} + onDelete={(id) => + confirm?.show(`Delete this contact?`, async () => { + setSelected([id]); + await bulkDelete(); + }) + } + emptyTitle={ + subFilter !== "all" + ? `No ${subFilter} contacts` + : current_campaign + ? "No contacts in this campaign" + : "No contacts yet" + } + emptyBody={ + subFilter !== "all" + ? "Switch to All to see the full list." + : "Add or upload contacts to get started." + } + emptyCta={ + subFilter !== "all" ? ( + setSubFilter("all")}> + Show all + + ) : ( + }>New contact + ) + } + hasNextPage={!!contactsData.hasNextPage} + isFetchingNextPage={contactsData.isFetchingNextPage} + onLoadMore={() => contactsData.fetchNextPage()} /> - ) + ); + + if (embedded) { + return ( + <> + + setSearchProps((s) => ({ ...s, query: v }))} + placeholder="Search leads…" + className="w-56" + /> + } + onClick={() => setFiltersOpen(true)} + > + Filters + + }>Add lead + +
+ {tableNode} + setBulkEdit(true)} + onDelete={() => + confirm?.show( + `Are you sure you want to delete ${selected.length} contacts?`, + bulkDelete, + ) + } + onClear={() => setSelected([])} + /> +
+ + + + + ); + } + + return ( + + + } + > + Import CSV + + } + > + Export + + }> + New contact + + + + + setSubFilter("all")} + /> + 0} + onClick={() => setSubFilter("subscribed")} + /> + setSubFilter("unsubscribed")} + /> + + + + + setSearchProps((s) => ({ ...s, query: v }))} + placeholder="Search by name, email, company…" + className="w-72" + /> + + + } + label="Sort" + /> + + + Sort by + {[ + ["created_at", "Date added"], + ["email", "Email"], + ["first_name", "First name"], + ["last_name", "Last name"], + ["company", "Company"], + ].map(([key, label]) => ( + + setSearchProps((s) => ({ + ...s, + sort_by: key as SearchContacts["sort_by"], + })) + } + > + {label} + + ))} + + setSearchProps((s) => ({ ...s, reverse: !s.reverse }))} + closeOnSelect={false} + > + Reverse order + + + + + } + onClick={() => setFiltersOpen(true)} + > + Filters + {searchProps.filters.length > 0 && ( + + {searchProps.filters.length} + + )} + + + + + {tableNode} + + + setBulkEdit(true)} + onDelete={() => + confirm?.show( + `Are you sure you want to delete ${selected.length} contacts?`, + bulkDelete, + ) + } + onClear={() => setSelected([])} + /> + + {filtered.length === 0 && !contactsData.isPending ? null : null} + + + + + + ); +} + +function ContactsTableBody({ + isLoading, + contacts, + selected, + onToggle, + isSelectedAll, + onToggleAll, + onRowClick, + onDelete, + emptyTitle, + emptyBody, + emptyCta, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: { + isLoading: boolean; + contacts: Array<{ + id: string; + first_name: string; + last_name: string; + email: string; + company: string; + phone: string; + subscribed: boolean; + campaigns: Array<{ id: string }>; + created_at: Date; + }>; + selected: string[]; + onToggle: (id: string, on: boolean) => void; + isSelectedAll: boolean; + onToggleAll: () => void; + onRowClick: (id: string) => void; + onDelete: (id: string) => void; + emptyTitle: string; + emptyBody: string; + emptyCta: React.ReactNode; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}) { + if (isLoading) { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + if (contacts.length === 0) { + return ; + } + return ( + <> + + + + + + + + + + + + + + + {contacts.map((c) => { + const isSel = selected.includes(c.id); + const name = + (c.first_name || c.last_name) + ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() + : c.email; + return ( + onRowClick(c.id)} + className={`group h-11 transition-colors cursor-pointer border-b border-slate-200/60 ${ + isSel ? "bg-sky-50/60" : "hover:bg-slate-50/80" + }`} + > + + + + + + + + + + ); + })} + +
+ + NameCompanyPhoneStatusCampaignsAdded
e.stopPropagation()} + > + onToggle(c.id, !isSel)} + /> + +
+
+ + {(c.first_name || c.email)?.slice(0, 2).toUpperCase()} + +
+
+
+ {name} +
+
+ + {c.email} +
+
+
+
+ {c.company ? ( + + + {c.company} + + ) : ( + + )} + + {c.phone ? ( + + + {c.phone} + + ) : ( + + )} + + + + {c.campaigns?.length ?? 0} + + {c.created_at + ? new Date(c.created_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + : "—"} + e.stopPropagation()}> +
+ + +
+
+ {hasNextPage && ( +
+ +
+ )} + + ); +} + +function Th({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function StatusPill({ subscribed }: { subscribed: boolean }) { + if (subscribed) { + return ( + + + subscribed + + ); + } + return ( + + + unsubscribed + + ); +} + +function SelectionBar({ + count, + deleting, + onBulkEdit, + onDelete, + onClear, +}: { + count: number; + deleting: boolean; + onBulkEdit: () => void; + onDelete: () => void; + onClear: () => void; +}) { + if (count === 0) return null; + return ( +
+
+ + {count} selected +
+ + +
+ +
+ ); } diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 20f9cfba..5ae167ca 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -1,40 +1,89 @@ -import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail' -import { useAppStore } from '@/stores' -import { cn } from '@/lib/utils' +import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; +import { useAppStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +function relative(d: Date): string { + const diff = Date.now() - d.getTime(); + const m = Math.floor(diff / 60_000); + if (m < 1) return "now"; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + const days = Math.floor(h / 24); + if (days < 7) return `${days}d`; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function fromName(s: string): string { + const m = s.match(/^"?([^"<]+)"?\s*<.+>$/); + if (m) return m[1].trim(); + return s.replace(/<.+>/, "").trim() || s; +} + +function initials(s: string): string { + const name = fromName(s); + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return (parts[0]?.slice(0, 2) ?? "??").toUpperCase(); +} interface ConversationItemProps { - email: UniboxEmail + email: UniboxEmail; } export function ConversationItem({ email }: ConversationItemProps) { - const selectedThreadId = useAppStore((s) => s.selectedThreadId) - const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId) + const selectedThreadId = useAppStore((s) => s.selectedThreadId); + const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); - const threadId = email.thread_id || email.id - const isSelected = selectedThreadId === threadId - const date = new Date(email.date) - const timeStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + const threadId = email.thread_id || email.id; + const isSelected = selectedThreadId === threadId; + const date = new Date(email.date); + const unread = !email.is_seen; + const preview = email.body.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").slice(0, 100); - return ( - - ) + return ( + + ); } diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index 3d8afd96..d6ba8a90 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -1,52 +1,101 @@ -import { useState } from 'react' -import { Input } from '@/components/ui/input' -import { ConversationItem } from './ConversationItem' -import { UniboxFilters } from './UniboxFilters' -import { useAppStore } from '@/stores' -import { SearchIcon } from 'lucide-react' +// Conversation list — middle pane of the unibox. +// +// Rows show from / subject / preview / time. Unread rows get a small +// sky rail on the left margin and a bolder from-name. + +import { useState } from "react"; +import { useAppStore } from "@/stores"; +import { SearchInput } from "@/components/ui/field"; +import { ConversationItem } from "./ConversationItem"; +import { SectionBar } from "@/components/layout/Page"; + +type Filter = "all" | "unread"; + +const FILTERS: Array<{ id: Filter; label: string }> = [ + { id: "all", label: "All" }, + { id: "unread", label: "Unread" }, +]; export function ConversationList() { - const [search, setSearch] = useState('') - const [filter, setFilter] = useState<'all' | 'unread'>('all') - const emails = useAppStore((s) => s.uniboxEmails) + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const emails = useAppStore((s) => s.uniboxEmails); - const filtered = emails.filter((email) => { - if (filter === 'unread' && email.is_seen) return false - if (search) { - const q = search.toLowerCase() - return ( - email.subject.toLowerCase().includes(q) || - email.from.toLowerCase().includes(q) - ) - } - return true - }) + const filtered = emails.filter((email) => { + if (filter === "unread" && email.is_seen) return false; + if (search) { + const q = search.toLowerCase(); + return ( + email.subject.toLowerCase().includes(q) || + email.from.toLowerCase().includes(q) + ); + } + return true; + }); - return ( - <> -
-
- - setSearch(e.target.value)} - className="pl-8 h-8 text-sm" - /> + const unreadCount = emails.filter((e) => !e.is_seen).length; + + return ( +
+ +
+ +
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+ {filtered.length === 0 ? ( +
+

+ {search + ? "No matches" + : filter === "unread" + ? "All caught up" + : "No conversations yet"} +

+

+ {search + ? "Try a different keyword." + : filter === "unread" + ? "When new mail arrives it'll show up here." + : "Replies and inbound mail land here automatically."} +

+
+ ) : ( +
+ {filtered.map((email) => ( + + ))} +
+ )} +
- -
-
- {filtered.length === 0 ? ( -
- No conversations found -
- ) : ( - filtered.map((email) => ( - - )) - )} -
- - ) + ); } diff --git a/web/src/components/app/unibox/MessageBubble.tsx b/web/src/components/app/unibox/MessageBubble.tsx index 8a54cf1c..d4020b06 100644 --- a/web/src/components/app/unibox/MessageBubble.tsx +++ b/web/src/components/app/unibox/MessageBubble.tsx @@ -1,34 +1,75 @@ -import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail' -import { Card, CardContent } from '@/components/ui/card' +// Single message in a thread. +// +// Header row holds sender (avatar + name + email), recipient(s), and +// timestamp. Body sits below in regular prose with light styling — no +// containing card, just hairlines between messages. + +import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; interface MessageBubbleProps { - email: UniboxEmail + email: UniboxEmail; +} + +function fromName(s: string): string { + const m = s.match(/^"?([^"<]+)"?\s*<.+>$/); + if (m) return m[1].trim(); + return s.replace(/<.+>/, "").trim() || s; +} + +function fromAddr(s: string): string | null { + const m = s.match(/<([^>]+)>/); + if (m) return m[1].trim(); + return null; +} + +function initials(s: string): string { + const name = fromName(s); + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return (parts[0]?.slice(0, 2) ?? "??").toUpperCase(); } export function MessageBubble({ email }: MessageBubbleProps) { - const date = new Date(email.date) - const timeStr = date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) + const date = new Date(email.date); + const dateStr = date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); - return ( - - -
-
- {email.from} - to {email.to} -
- {timeStr} -
-
- - - ) + const name = fromName(email.from); + const addr = fromAddr(email.from); + + return ( +
+
+
+ {initials(email.from)} +
+
+
+ + {name} + + {addr && ( + + {addr} + + )} +
+
+ to {email.to} +
+
+ + {dateStr} + +
+
+
+ ); } diff --git a/web/src/components/app/unibox/ReplyComposer.tsx b/web/src/components/app/unibox/ReplyComposer.tsx index 9fb6b05b..e08156ba 100644 --- a/web/src/components/app/unibox/ReplyComposer.tsx +++ b/web/src/components/app/unibox/ReplyComposer.tsx @@ -1,71 +1,126 @@ -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { SendIcon } from 'lucide-react' -import toast from 'react-hot-toast' -import sendReply from '@/lib/api/client/app/unibox/sendReply' -import type UniboxEmail from '@/lib/api/models/app/unibox/UniboxEmail' +// Reply composer — pinned to the bottom of the thread pane. +// +// Slim chrome: a textarea with a hairline border on top of an action +// bar (send + cancel + schedule placeholder). ⌘+Enter sends; Esc +// clears focus. Reads as a quick reply, not a full editor. + +import { useState } from "react"; +import { ChevronDownIcon, SendIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import sendReply from "@/lib/api/client/app/unibox/sendReply"; +import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; interface ReplyComposerProps { - threadId: string - threadEmails: UniboxEmail[] + threadId: string; + threadEmails: UniboxEmail[]; } export function ReplyComposer({ threadId, threadEmails }: ReplyComposerProps) { - const [reply, setReply] = useState('') - const [isSending, setIsSending] = useState(false) + const [reply, setReply] = useState(""); + const [isSending, setIsSending] = useState(false); - const handleSend = async () => { - if (!reply.trim()) return + const handleSend = async () => { + if (!reply.trim()) return; - const latestEmail = threadEmails[threadEmails.length - 1] - if (!latestEmail?.account_id) { - toast.error('Cannot determine sender account for this thread') - return - } + const latestEmail = threadEmails[threadEmails.length - 1]; + if (!latestEmail?.account_id) { + toast.error("Cannot determine sender account for this thread"); + return; + } - const replyTo = latestEmail.from?.trim() - if (!replyTo) { - toast.error('Cannot determine recipient for this reply') - return - } + const replyTo = latestEmail.from?.trim(); + if (!replyTo) { + toast.error("Cannot determine recipient for this reply"); + return; + } - const subjectBase = latestEmail.subject?.trim() || 'Re:' - const subject = /^re:/i.test(subjectBase) ? subjectBase : `Re: ${subjectBase}` + const subjectBase = latestEmail.subject?.trim() || "Re:"; + const subject = /^re:/i.test(subjectBase) ? subjectBase : `Re: ${subjectBase}`; - setIsSending(true) - try { - await sendReply({ - email_account_id: latestEmail.account_id, - to: [replyTo], - subject, - body_plain: reply.trim(), - body_html: reply.trim().replace(/\n/g, '
'), - thread_id: threadId, - send_mode: 'instant', - }) - setReply('') - toast.success('Reply queued') - } catch { - toast.error('Failed to send reply') - } finally { - setIsSending(false) - } - } + setIsSending(true); + try { + await sendReply({ + email_account_id: latestEmail.account_id, + to: [replyTo], + subject, + body_plain: reply.trim(), + body_html: reply.trim().replace(/\n/g, "
"), + thread_id: threadId, + send_mode: "instant", + }); + setReply(""); + toast.success("Reply queued"); + } catch { + toast.error("Failed to send reply"); + } finally { + setIsSending(false); + } + }; - return ( -
-